"""Manage Humble Bundle libraries""" import concurrent.futures import json import os from gettext import gettext as _ from gi.repository import Gtk from lutris import settings from lutris.exceptions import UnavailableGameError from lutris.gui.dialogs import HumbleBundleCookiesDialog, QuestionDialog from lutris.installer import AUTO_ELF_EXE, AUTO_WIN32_EXE from lutris.installer.installer_file import InstallerFile from lutris.services.base import SERVICE_LOGIN, OnlineService from lutris.services.service_game import ServiceGame from lutris.services.service_media import ServiceMedia from lutris.util import linux from lutris.util.http import HTTPError, Request from lutris.util.log import logger class HumbleBundleIcon(ServiceMedia): """HumbleBundle icon""" service = "humblebundle" size = (70, 70) dest_path = os.path.join(settings.CACHE_DIR, "humblebundle/icons") file_patterns = ["%s.png"] api_field = "icon" class HumbleSmallIcon(HumbleBundleIcon): size = (35, 35) can_be_fallback = False def get_fallback_media_path(self, services): return None class HumbleBigIcon(HumbleBundleIcon): size = (105, 105) class HumbleBundleGame(ServiceGame): """Service game for DRM free Humble Bundle games""" service = "humblebundle" @classmethod def new_from_humble_game(cls, humble_game): """Converts a game from the API to a service game usable by Lutris""" service_game = HumbleBundleGame() service_game.appid = humble_game["machine_name"] service_game.slug = humble_game["machine_name"] service_game.name = humble_game["human_name"] service_game.details = json.dumps(humble_game) return service_game class HumbleBundleService(OnlineService): """Service for Humble Bundle""" id = "humblebundle" _matcher = "humble" name = _("Humble Bundle") icon = "humblebundle" online = True drm_free = True medias = {"small_icon": HumbleSmallIcon, "icon": HumbleBundleIcon, "big_icon": HumbleBigIcon} default_format = "icon" api_url = "https://www.humblebundle.com/" login_url = "https://www.humblebundle.com/login?goto=/home/library" redirect_uris = ["https://www.humblebundle.com/home/library"] cookies_path = os.path.join(settings.CACHE_DIR, ".humblebundle.auth") token_path = os.path.join(settings.CACHE_DIR, ".humblebundle.token") cache_path = os.path.join(settings.CACHE_DIR, "humblebundle/library/") supported_platforms = ("linux", "windows") def login(self, parent=None): dialog = QuestionDialog( { "title": _("Workaround for Humble Bundle authentication"), "question": _( "Humble Bundle is restricting API calls from software like Lutris and GameHub.\n" "Authentication to the service will likely fail.\n" "There is a workaround involving copying cookies " "from Firefox, do you want to do this instead?" ), "parent": parent, } ) if dialog.result == Gtk.ResponseType.YES: dialog = HumbleBundleCookiesDialog() if dialog.cookies_content: with open(self.cookies_path, "w", encoding="utf-8") as cookies_file: cookies_file.write(dialog.cookies_content) SERVICE_LOGIN.fire(self) else: self.logout() else: return super().login(parent=parent) def login_callback(self, url): """Called after the user has logged in successfully""" SERVICE_LOGIN.fire(self) def is_connected(self): """This doesn't actually check if the authentication is valid like the GOG service does. """ return self.is_authenticated() def load(self): """Load the user's Humble Bundle library""" try: library = self.get_library() except ValueError as ex: raise RuntimeError("Failed to get Humble Bundle library. Try logging out and back-in.") from ex humble_games = [] seen = set() for game in library: if game["human_name"] in seen: continue humble_games.append(HumbleBundleGame.new_from_humble_game(game)) seen.add(game["human_name"]) for game in humble_games: game.save() return humble_games def make_api_request(self, url): """Make an authenticated request to the Humble API""" request = Request(url, cookies=self.load_cookies()) try: request.get() except HTTPError: logger.error("Failed to request %s", url) return return request.json def order_path(self, gamekey): """Return the local path for an order""" return os.path.join(self.cache_path, "%s.json" % gamekey) def get_order(self, gamekey): """Retrieve an order identitied by its key""" # logger.debug("Getting Humble Bundle order %s", gamekey) cache_filename = self.order_path(gamekey) if os.path.exists(cache_filename): with open(cache_filename, encoding="utf-8") as cache_file: return json.load(cache_file) response = self.make_api_request(self.api_url + "api/v1/order/%s?all_tpkds=true" % gamekey) os.makedirs(self.cache_path, exist_ok=True) with open(cache_filename, "w", encoding="utf-8") as cache_file: json.dump(response, cache_file) return response def get_library(self): """Return the games from the user's library""" games = [] for order in self.get_orders(): if not order: continue for product in order["subproducts"]: for download in product["downloads"]: if download["platform"] in self.supported_platforms: games.append(product) return games def get_gamekeys_from_local_orders(self): """Retrieve a list of orders from the cache.""" game_keys = [] if os.path.exists(self.cache_path): for order_file in os.listdir(self.cache_path): if not order_file.endswith(".json"): continue game_keys.append({"gamekey": order_file[:-5]}) return game_keys def get_orders(self): """Return all orders""" gamekeys = self.get_gamekeys_from_local_orders() orders = [] if not gamekeys: gamekeys = self.make_api_request(self.api_url + "api/v1/user/order") with concurrent.futures.ThreadPoolExecutor(max_workers=8) as executor: future_orders = [executor.submit(self.get_order, gamekey["gamekey"]) for gamekey in gamekeys] for order in future_orders: orders.append(order.result()) logger.info("Loaded %s Humble Bundle orders", len(orders)) return orders @staticmethod def find_download_in_order(order, humbleid, platform): """Return the download information in an order for a give game""" for product in order["subproducts"]: if product["machine_name"] != humbleid: continue available_platforms = [d["platform"] for d in product["downloads"]] if platform not in available_platforms: logger.warning( "Requested platform %s not available in available platforms: %s", platform, available_platforms ) if "linux" in available_platforms: platform = "linux" elif "windows" in available_platforms: platform = "windows" else: platform = available_platforms[0] for download in product["downloads"]: if download["platform"] != platform: continue return { "product": order["product"], "gamekey": order["gamekey"], "created": order["created"], "download": download, } def get_downloads(self, humbleid, platform): """Return the download information for a given game""" download_links = [] for order in self.get_orders(): download = self.find_download_in_order(order, humbleid, platform) if download: download_links.append(download) return download_links def get_installer_files(self, installer, installer_file_id, _selected_extras): """Replace the user provided file with download links from Humble Bundle""" try: link = get_humble_download_link(installer.service_appid, installer.runner) except Exception as ex: logger.exception("Failed to get Humble Bundle game: %s", ex) raise UnavailableGameError(_("The download URL for the game could not be determined.")) from ex if not link: raise UnavailableGameError(_("No game found on Humble Bundle")) filename = link.split("?")[0].split("/")[-1] file = InstallerFile(installer.game_slug, installer_file_id, {"url": link, "filename": filename}) return [file], [] @staticmethod def get_filename_for_platform(downloads, platform): download = [d for d in downloads if d["platform"] == platform][0] url = pick_download_url_from_download_info(download) if not url: return return url.split("?")[0].split("/")[-1] @staticmethod def platform_has_downloads(downloads, platform): for download in downloads: if download["platform"] != platform: continue if len(download["download_struct"]) > 0: return True def generate_installer(self, db_game): details = json.loads(db_game["details"]) platforms = [download["platform"] for download in details["downloads"]] system_config = {} if "linux" in platforms and self.platform_has_downloads(details["downloads"], "linux"): runner = "linux" game_config = {"exe": AUTO_ELF_EXE} filename = self.get_filename_for_platform(details["downloads"], "linux") if filename.lower().endswith(".sh"): script = [ {"extract": {"file": "humblegame", "format": "zip", "dst": "$CACHE"}}, {"merge": {"src": "$CACHE/data/noarch", "dst": "$GAMEDIR", "optional": True}}, {"move": {"src": "$CACHE/data/noarch", "dst": "$CACHE/noarch", "optional": True}}, {"merge": {"src": "$CACHE/data/x86_64", "dst": "$GAMEDIR", "optional": True}}, {"move": {"src": "$CACHE/data/x86_64", "dst": "$CACHE/x86_64", "optional": True}}, {"merge": {"src": "$CACHE/data/x86", "dst": "$GAMEDIR", "optional": True}}, {"move": {"src": "$CACHE/data/x86", "dst": "$CACHE/x86", "optional": True}}, {"merge": {"src": "$CACHE/data/", "dst": "$GAMEDIR", "optional": True}}, ] elif filename.endswith("-bin") or filename.endswith("mojo.run"): script = [ {"extract": {"file": "humblegame", "format": "zip", "dst": "$CACHE"}}, {"merge": {"src": "$CACHE/data/", "dst": "$GAMEDIR"}}, ] elif filename.endswith(".air"): script = [ {"move": {"src": "humblegame", "dst": "$GAMEDIR"}}, ] else: script = [{"extract": {"file": "humblegame"}}] system_config = {"gamemode": "false"} # Unity games crash with gamemode elif "windows" in platforms: runner = "wine" game_config = {"exe": AUTO_WIN32_EXE, "prefix": "$GAMEDIR"} filename = self.get_filename_for_platform(details["downloads"], "windows") if filename.lower().endswith(".zip"): script = [ {"task": {"name": "create_prefix", "prefix": "$GAMEDIR"}}, {"extract": {"file": "humblegame", "dst": "$GAMEDIR/drive_c/%s" % db_game["slug"]}}, ] else: script = [{"task": {"name": "wineexec", "executable": "humblegame"}}] else: logger.warning("Unsupported platforms: %s", platforms) return {} return { "name": db_game["name"], "version": "Humble Bundle", "slug": details["machine_name"], "game_slug": self.get_installed_slug(db_game), "runner": runner, "humbleid": db_game["appid"], "script": { "game": game_config, "system": system_config, "files": [{"humblegame": "N/A:Select the installer from Humble Bundle"}], "installer": script, }, } def get_installed_runner_name(self, db_game): details = json.loads(db_game["details"]) platforms = [download["platform"] for download in details["downloads"]] if "linux" in platforms and self.platform_has_downloads(details["downloads"], "linux"): return "linux" if "windows" in platforms: return "wine" return "" def pick_download_url_from_download_info(download_info): """From a list of downloads in Humble Bundle, pick the most appropriate one for the installer. This needs a way to be explicitely filtered. """ if not download_info["download_struct"]: logger.warning("No downloads found") return def humble_sort(download): name = download["name"] if "rpm" in name: return -99 # Not supported as an extractor bonus = 1 if "deb" not in name: bonus = 2 if linux.LINUX_SYSTEM.is_64_bit: if "386" in name or "32" in name: return -1 else: if "64" in name: return -10 return 1 * bonus sorted_downloads = sorted(download_info["download_struct"], key=humble_sort, reverse=True) logger.debug("Humble bundle installers:") for download in sorted_downloads: logger.debug(download) return sorted_downloads[0]["url"]["web"] def get_humble_download_link(humbleid, runner): """Return a download link for a given humbleid and runner""" service = HumbleBundleService() platform = runner if runner != "wine" else "windows" downloads = service.get_downloads(humbleid, platform) if not downloads: logger.error("Game %s for %s not found in the Humble Bundle library", humbleid, platform) return logger.info("Found %s download for %s", len(downloads), humbleid) download = downloads[0] logger.info("Reloading order %s", download["product"]["human_name"]) os.remove(service.order_path(download["gamekey"])) order = service.get_order(download["gamekey"]) download_info = service.find_download_in_order(order, humbleid, platform) if download_info: return pick_download_url_from_download_info(download_info["download"]) logger.warning("Couldn't retrieve any downloads for %s", humbleid)