Skip to content
Merged
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
1 change: 1 addition & 0 deletions pupgui2/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
{'install_dir': '~/.var/app/com.heroicgameslauncher.hgl/config/heroic/tools/proton/', 'display_name': 'Heroic Proton Flatpak', 'launcher': 'heroicproton', 'type': 'flatpak', 'icon': 'heroic'},
{'install_dir': '~/.local/share/bottles/runners/', 'display_name': 'Bottles', 'launcher': 'bottles', 'type': 'native', 'icon': 'com.usebottles.bottles'},
{'install_dir': '~/.var/app/com.usebottles.bottles/data/bottles/runners/', 'display_name': 'Bottles Flatpak', 'launcher': 'bottles', 'type': 'flatpak', 'icon': 'com.usebottles.bottles'},
{'install_dir': '~/.local/share/com.epicgames.eglv2/compat/runtime/', 'display_name': 'Epic Games Launcher', 'launcher': 'epicgames', 'type': 'native', 'icon': 'EpicGamesLauncher'},
{'install_dir': '~/.local/share/winezgui/Runners/', 'display_name': 'WineZGUI', 'launcher': 'winezgui', 'type': 'native', 'icon': 'io.github.fastrizwaan.WineZGUI'},
{'install_dir': '~/.var/app/io.github.fastrizwaan.WineZGUI/data/winezgui/Runners/', 'display_name': 'WineZGUI Flatpak', 'launcher': 'winezgui', 'type': 'flatpak', 'icon': 'io.github.fastrizwaan.WineZGUI'}
]
Expand Down
25 changes: 25 additions & 0 deletions pupgui2/datastructures.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,13 +256,38 @@ def get_game_config(self):
return json.load(gcf).get(self.app_name, {})


# Information for games installed by the Epic Games Launcher (v2) is stored in '<epic_dir>/installations.jsonl'
# Game titles are stored in the catalog cache at '<epic_dir>/cache/prod/catalog-cache.json'
class EpicGame:
installation_id: str # e.g. '47954A6D608DF84ED0342F0EE9349302'
namespace: str # Sandbox id of the game
item_id: str # Catalog item id, used to look up the title
artifact_id: str # Internal app name, also used as name for the Proton prefix in 'compat/compatdata'
title: str # Real name for game, falls back to the install folder name if not cached
install_path: str # Path to game folder, e.g. '/home/Gaben/Games/Half-Life 3'
platform: str # 'Windows' or 'Linux'
executable: str # Game executable relative to install_path, e.g. 'Among Us.exe'
version: str # Build version
epic_dir: str # e.g. '~/.local/share/com.epicgames.eglv2'
compat_tool: str # Proton runtime used to run the game, empty for native games or if it cannot be determined

def is_windows(self) -> bool:
""" Returns True if the game is run with Proton """
return self.platform.lower() == 'windows'

def get_prefix_path(self) -> str:
""" Returns the path to the Proton prefix of the game. The prefix may not exist if the game was never started. """
return os.path.join(self.epic_dir, 'compat', 'compatdata', self.artifact_id, 'pfx')


class Launcher(Enum):
UNKNOWN = 0
STEAM = 1
LUTRIS = 2
BOTTLES = 3
HEROIC = 4
WINEZGUI = 5
EPIC = 6

class HeroicCompatTool:
displayname = ''
Expand Down
145 changes: 145 additions & 0 deletions pupgui2/epicutil.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import os
import json

from typing import Any

from pupgui2.datastructures import EpicGame


# The Epic Games Launcher (v2, data in '~/.local/share/com.epicgames.eglv2') runs Windows games with a single,
# launcher-managed Proton runtime. The runtime version is pinned by Epic and installed to 'compat/runtime/<version>',
# prefixes are created per game in 'compat/compatdata/<artifactId>/pfx'. There is no per-game compatibility tool setting.


def get_epic_dir_from_install_dir(install_dir: str) -> str:
""" Returns the Epic Games Launcher data directory for the runtime install directory, e.g. '~/.local/share/com.epicgames.eglv2' """

return os.path.abspath(os.path.join(os.path.expanduser(install_dir), '../..'))


def is_epic_launcher(launcher: str) -> bool:
""" Returns True if the supplied launcher name is the Epic Games Launcher """

return launcher == 'epicgames'


def load_epic_json(path: str) -> dict[str, Any]:
""" Returns the JSON object stored at 'path', or an empty dict if the file is missing, unreadable or not a JSON object """

if not os.path.isfile(path):
return {}

try:
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
except (OSError, ValueError) as e: # ValueError includes JSONDecodeError and UnicodeDecodeError
print(f'Could not read Epic Games Launcher file \'{path}\': {e}')
return {}

return data if isinstance(data, dict) else {}


def get_epic_runtimes(epic_dir: str) -> list[str]:
""" Returns the folder names of all Proton runtimes installed in '<epic_dir>/compat/runtime', e.g. ['GE-Proton10-34'] """

runtime_dir = os.path.join(epic_dir, 'compat', 'runtime')
if not os.path.isdir(runtime_dir):
return []

return [d for d in os.listdir(runtime_dir) if os.path.isfile(os.path.join(runtime_dir, d, 'proton'))]


def get_epic_pinned_runtime(epic_dir: str) -> str:
"""
Returns the folder name of the installed Proton runtime that the Epic Games Launcher uses for all Windows games, e.g. 'GE-Proton10-34'.
Returns an empty string if it cannot be determined.
"""

runtimes = get_epic_runtimes(epic_dir)

version = load_epic_json(os.path.join(epic_dir, 'compat', 'runtime', 'pin-cache.json')).get('version', '')
if version in runtimes:
return version

# Pinned version is unknown or not installed (yet): If there is only one runtime installed, the launcher has to use that one
if len(runtimes) == 1:
return runtimes[0]

return ''


def get_epic_game_titles(epic_dir: str) -> dict[str, str]:
""" Returns a dict mapping the catalog item id to the game title, read from the launcher's catalog cache """

catalog_items = load_epic_json(os.path.join(epic_dir, 'cache', 'prod', 'catalog-cache.json')).get('items', {})
if not isinstance(catalog_items, dict):
return {}

titles: dict[str, str] = {}
for item_id, entry in catalog_items.items():
item = entry.get('item') if isinstance(entry, dict) else None
if isinstance(item, dict) and isinstance(item.get('title'), str):
titles[item_id] = item['title']

return titles


def get_epic_game_list(epic_dir: str, pinned_runtime: str | None = None, with_titles: bool = True) -> list[EpicGame]:
"""
Returns a list of installed games for the Epic Games Launcher at 'epic_dir' (e.g. '~/.local/share/com.epicgames.eglv2')
pinned_runtime: Proton runtime used by Windows games, determined with get_epic_pinned_runtime if None
with_titles: Look up game titles in the catalog cache, otherwise the install folder name is used as title
Return Type: list[EpicGame]
"""

installations_path = os.path.join(epic_dir, 'installations.jsonl')
if not os.path.isfile(installations_path):
return []

# installations.jsonl is an append-only journal: the first line is a header, every other line is a full
# snapshot of one installation. The last snapshot for an installationId is the current state.
installations: dict[str, dict[str, Any]] = {}
try:
with open(installations_path, 'r', encoding='utf-8') as f:
for line in f:
try:
entry = json.loads(line)
except ValueError:
continue
if isinstance(entry, dict) and isinstance(entry.get('installationId'), str):
installations[entry['installationId']] = entry
except (OSError, ValueError) as e:
print(f'Could not read Epic Games Launcher installations \'{installations_path}\': {e}')
return []

titles = get_epic_game_titles(epic_dir) if with_titles else {}
if pinned_runtime is None:
pinned_runtime = get_epic_pinned_runtime(epic_dir)

def get_str(entry: dict[str, Any], key: str) -> str:
value = entry.get(key)
return value if isinstance(value, str) else ''

egs: list[EpicGame] = []
for installation_id, entry in installations.items():
install_path = get_str(entry, 'dir')
if entry.get('complete') is not True or not install_path or not os.path.isdir(install_path):
continue

eg = EpicGame()

eg.installation_id = installation_id
eg.namespace = get_str(entry, 'namespace')
eg.item_id = get_str(entry, 'itemId')
eg.artifact_id = get_str(entry, 'artifactId')
eg.install_path = install_path
eg.platform = get_str(entry, 'platform').capitalize()
eg.executable = get_str(entry, 'launchExecutable')
eg.version = get_str(entry, 'version')
eg.title = titles.get(eg.item_id, '') or os.path.basename(os.path.normpath(install_path))
eg.epic_dir = epic_dir
eg.compat_tool = pinned_runtime if eg.is_windows() else ''

egs.append(eg)

return egs
27 changes: 27 additions & 0 deletions pupgui2/pupgui2.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from pupgui2.pupgui2installdialog import PupguiInstallDialog
from pupgui2.steamutil import get_steam_acruntime_list, get_steam_app_list, get_steam_ct_game_map, get_steam_global_ctool_name, ctool_is_runtime_for_app
from pupgui2.heroicutil import is_heroic_launcher, get_heroic_game_list
from pupgui2.epicutil import is_epic_launcher, get_epic_game_list, get_epic_pinned_runtime, get_epic_runtimes, get_epic_dir_from_install_dir
from pupgui2.dbusutil import dbus_progress_message
from pupgui2.util import apply_dark_theme, create_compatibilitytools_folder, get_installed_ctools, remove_ctool
from pupgui2.util import install_directory, available_install_directories, get_install_location_from_directory_name
Expand Down Expand Up @@ -274,6 +275,24 @@ def update_ui(self):
vkd3d_dir = os.path.join(install_directory(), '../vkd3d')
self.get_installed_versions('dxvk', dxvk_dir)
self.get_installed_versions('vkd3d', vkd3d_dir)
# Launcher specific (Epic Games): All Windows games use the Proton runtime pinned by the launcher
elif is_epic_launcher(install_loc.get('launcher')):
epic_dir = get_epic_dir_from_install_dir(install_loc.get('install_dir'))
pinned_runtime = get_epic_pinned_runtime(epic_dir)
epic_runtimes = get_epic_runtimes(epic_dir)
no_windows_games = len([game for game in get_epic_game_list(epic_dir, pinned_runtime=pinned_runtime, with_titles=False) if game.is_windows()])

# Only list Proton runtimes, other folders (e.g. staging) are managed by the launcher
self.compat_tool_index_map = [ct for ct in self.compat_tool_index_map if ct.get_install_folder() in epic_runtimes]
for ct in self.compat_tool_index_map:
if ct.get_install_folder() == pinned_runtime:
ct.no_games = no_windows_games
ct.set_global()
self.compat_tool_index_map.insert(0, self.compat_tool_index_map.pop(self.compat_tool_index_map.index(ct)))
elif not pinned_runtime:
ct.no_games = no_windows_games # Unknown which runtime is used, assume it could be any of them
else:
ct.no_games = 0

for ct in self.compat_tool_index_map:
self.ui.listInstalledVersions.addItem(ct.get_displayname(unused_tr=self.tr('unused'), global_tr=self.tr('global')))
Expand All @@ -294,6 +313,8 @@ def update_ui(self):
self.ui.btnShowGameList.setVisible(True)
elif is_heroic_launcher(install_loc.get('launcher')):
self.ui.btnShowGameList.setVisible(True)
elif is_epic_launcher(install_loc.get('launcher')):
self.ui.btnShowGameList.setVisible(True)
else:
self.ui.btnShowGameList.setVisible(False)

Expand Down Expand Up @@ -359,6 +380,8 @@ def set_download_progress_percent(self, value):
def btn_add_version_clicked(self, compat_tool: str = ''):
advanced_mode = (config_advanced_mode() == 'enabled')
install_loc = get_install_location_from_directory_name(install_directory())
if is_epic_launcher(install_loc.get('launcher', '')):
return

if not compat_tool or compat_tool_available(compat_tool, self.ct_loader.get_ctobjs(install_loc, advanced_mode=advanced_mode)):
dialog = PupguiInstallDialog(install_loc, self.ct_loader, parent=self.ui)
Expand Down Expand Up @@ -423,6 +446,10 @@ def show_launcher_specific_information(self):
self.ui.btnSteamFlatpakCtools.setVisible(
'steam' in install_loc.get('launcher', '') and 'Flatpak' in install_loc.get('display_name', '')
)
# The Epic Games Launcher downloads and manages its Proton runtime itself and ignores other versions
is_epic = is_epic_launcher(install_loc.get('launcher', ''))
self.ui.btnAddVersion.setEnabled(not is_epic)
self.ui.btnAddVersion.setToolTip(self.tr('The Epic Games Launcher manages its Proton version itself') if is_epic else '')

def list_installed_versions_item_double_clicked(self, item):
""" Show info about compatibility tool when double clicked in list """
Expand Down
64 changes: 61 additions & 3 deletions pupgui2/pupgui2gamelistdialog.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import os
import pkgutil
import subprocess

from typing import Callable
from datetime import datetime
Expand All @@ -10,13 +11,14 @@
from PySide6.QtUiTools import QUiLoader

from pupgui2.constants import PROTONDB_COLORS, STEAM_APP_PAGE_URL, AWACY_WEB_URL, PROTONDB_APP_PAGE_URL, LUTRIS_WEB_URL, IS_FLATPAK
from pupgui2.datastructures import AWACYStatus, SteamApp, SteamDeckCompatEnum, LutrisGame, HeroicGame
from pupgui2.datastructures import AWACYStatus, SteamApp, SteamDeckCompatEnum, LutrisGame, HeroicGame, EpicGame
from pupgui2.lutrisutil import get_lutris_game_list, is_lutris_game_using_runner
from pupgui2.pupgui2shortcutdialog import PupguiShortcutDialog
from pupgui2.steamutil import steam_update_ctools, get_steam_game_list
from pupgui2.steamutil import is_steam_running, get_steam_ctool_list
from pupgui2.steamutil import get_protondb_status
from pupgui2.heroicutil import get_heroic_game_list, get_heroic_ctool_list, is_heroic_launcher, heroic_update_ctool
from pupgui2.epicutil import get_epic_game_list, is_epic_launcher, get_epic_dir_from_install_dir
from pupgui2.util import list_installed_ctools, sort_compatibility_tool_names, open_webbrowser_thread
from pupgui2.util import get_install_location_from_directory_name, get_random_game_name

Expand All @@ -31,7 +33,7 @@ def __init__(self, install_dir, parent=None):
self.install_dir = install_dir
self.parent = parent
self.queued_changes = {}
self.games: list[SteamApp | LutrisGame | HeroicGame] = []
self.games: list[SteamApp | LutrisGame | HeroicGame | EpicGame] = []

self.install_loc = get_install_location_from_directory_name(install_dir)
self.launcher = self.install_loc.get('launcher', '')
Expand All @@ -53,14 +55,21 @@ def setup_ui(self):
self.setup_lutris_list_ui()
elif is_heroic_launcher(self.launcher):
self.setup_heroic_list_ui()
elif is_epic_launcher(self.launcher):
self.setup_epic_list_ui()

self.ui.btnShortcutEditor.setVisible(self.launcher == 'steam')

self.ui.btnSearch.setVisible(False)
self.ui.searchBox.setVisible(False) # Hide searchbox by default

self.set_apply_btn_text()
self.ui.setWindowTitle(self.tr('Game List for {LAUNCHER}').format(LAUNCHER=self.launcher.capitalize() if not is_heroic_launcher(self.launcher) else 'Heroic'))
launcher_name = self.launcher.capitalize()
if is_heroic_launcher(self.launcher):
launcher_name = 'Heroic'
elif is_epic_launcher(self.launcher):
launcher_name = 'Epic Games'
self.ui.setWindowTitle(self.tr('Game List for {LAUNCHER}').format(LAUNCHER=launcher_name))

self.ui.lblSteamRunningWarning.setVisible(self.should_show_steam_warning) # Only show warning if Steam is running, and make it grey if we're running in Flatpak
self.ui.tableGames.horizontalHeaderItem(0).setToolTip(self.tr('Installed games: {NO_INSTALLED}').format(NO_INSTALLED=str(len(self.games))))
Expand Down Expand Up @@ -109,6 +118,16 @@ def setup_heroic_list_ui(self):
self.ui.tableGames.setColumnWidth(3, 40)
self.ui.tableGames.setColumnHidden(4, True)

def setup_epic_list_ui(self):
self.ui.tableGames.setHorizontalHeaderLabels([self.tr('Game'), self.tr('Compatibility Tool'), self.tr('Install Location'), self.tr('Platform'), ''])
self.update_game_list_epic()

self.ui.tableGames.setColumnWidth(0, 270)
self.ui.tableGames.setColumnWidth(1, 170)
self.ui.tableGames.setColumnWidth(2, 250)
self.ui.tableGames.setColumnWidth(3, 70)
self.ui.tableGames.setColumnHidden(4, True)

def update_game_list_steam(self, cached=True):
""" update the game list for the Steam launcher """
self.games: list[SteamApp] = get_steam_game_list(steam_config_folder=self.install_loc.get('vdf_dir'), cached=cached)
Expand Down Expand Up @@ -308,6 +327,43 @@ def update_game_list_heroic(self):
self.ui.tableGames.setItem(i, 2, install_path_item)
self.ui.tableGames.setItem(i, 3, runner_item)

def update_game_list_epic(self):
epic_dir = get_epic_dir_from_install_dir(self.install_loc.get('install_dir'))
self.games: list[EpicGame] = get_epic_game_list(epic_dir)

self.ui.tableGames.setRowCount(len(self.games))

for i, game in enumerate(self.games):
title_item = QTableWidgetItem(game.title)
title_item.setToolTip(f'{game.title} ({game.executable})' if game.executable else game.title)

compat_item = QTableWidgetItem()
if game.is_windows():
compat_item_text = game.compat_tool or self.tr('Unknown')
compat_tool_tooltip = self.tr('Proton runtime managed by the Epic Games Launcher')
prefix_path = game.get_prefix_path()
if os.path.isdir(prefix_path):
compat_tool_tooltip += self.tr('\nPrefix: {PREFIX}').format(PREFIX=prefix_path)
compat_item.setData(Qt.UserRole, lambda _, path=prefix_path: subprocess.Popen(['xdg-open', path]))
else:
compat_item_text = self.tr('Native')
compat_tool_tooltip = self.tr('Type: {PLATFORM}').format(PLATFORM=game.platform)

compat_item.setText(compat_item_text)
compat_item.setToolTip(compat_tool_tooltip)
compat_item.setTextAlignment(Qt.AlignCenter)

install_path_item = QTableWidgetItem(game.install_path)
self.set_item_data_directory(install_path_item, game.install_path)

platform_item = QTableWidgetItem(game.platform)
platform_item.setTextAlignment(Qt.AlignCenter)

self.ui.tableGames.setItem(i, 0, title_item)
self.ui.tableGames.setItem(i, 1, compat_item)
self.ui.tableGames.setItem(i, 2, install_path_item)
self.ui.tableGames.setItem(i, 3, platform_item)

def set_apply_btn_text(self):
""" Set text for Apply button to 'Close' if the games list is empty, if the current launcher is not Steam or if there are no queued changes."""

Expand All @@ -326,6 +382,8 @@ def btn_refresh_games_clicked(self):
self.update_game_list_lutris()
elif is_heroic_launcher(self.launcher):
self.update_game_list_heroic()
elif is_epic_launcher(self.launcher):
self.update_game_list_epic()

self.update_tooltip()

Expand Down
Loading
Loading