diff --git a/bottles/backend/downloader.py b/bottles/backend/downloader.py index dc23aaffc5..1b9d4f8fa1 100644 --- a/bottles/backend/downloader.py +++ b/bottles/backend/downloader.py @@ -71,6 +71,7 @@ def download(self) -> Result: headers=headers, timeout=(10, 30), ) + response.raise_for_status() total_size = int(response.headers.get("content-length", 0)) received_size = 0 @@ -103,10 +104,10 @@ def download(self) -> Result: "Your system may have a wrong date/time or wrong certificates." ) return Result(False, message="Download failed due to a SSL error.") - except (requests.exceptions.RequestException, OSError): + except (requests.exceptions.RequestException, OSError) as error: with suppress(OSError): os.remove(self.file) - logging.error("Download failed! Check your internet connection.") + logging.error(f"Failed to download [{self.url}]: {error}") return Result( False, message="Download failed! Check your internet connection." ) diff --git a/bottles/backend/managers/component.py b/bottles/backend/managers/component.py index 593ad3167e..ddecb2fb1d 100644 --- a/bottles/backend/managers/component.py +++ b/bottles/backend/managers/component.py @@ -27,8 +27,6 @@ from threading import Event from typing import Optional -import pycurl - from bottles.backend.downloader import Downloader from bottles.backend.globals import Paths from bottles.backend.logger import Logger @@ -324,67 +322,25 @@ def download( return Result(False, message="File is not available in offline mode.") if not os.path.isfile(file_path): - """ - As some urls can be redirect, we need to take care of this - and make sure to use the final url. This check should be - skipped for large files (e.g. runners). - """ - c = pycurl.Curl() - _proxy = os.environ.get("http_proxy") or os.environ.get("https_proxy") - if _proxy: - c.setopt(pycurl.PROXY, _proxy) - try: - c.setopt(c.URL, download_url) # type: ignore - c.setopt(c.FOLLOWLOCATION, True) # type: ignore - c.setopt(c.HTTPHEADER, ["User-Agent: curl/7.79.1"]) # type: ignore - c.setopt(c.NOBODY, True) # type: ignore - c.setopt(pycurl.CONNECTTIMEOUT, 10) - c.setopt(pycurl.TIMEOUT, 30) - c.perform() - - req_code = c.getinfo(c.RESPONSE_CODE) # type: ignore - download_url = c.getinfo(c.EFFECTIVE_URL) # type: ignore - except pycurl.error: - logging.exception(f"Failed to download [{download_url}]") + res = Downloader( + url=download_url, + file=temp_dest, + update_func=update_func, + cancel_event=cancel_event, + ).download() + + if not res.ok: if not external_task: TaskManager.remove(task_id) - return Result(False) - finally: - c.close() + return res - if req_code == 200: - """ - If the status code is 200, the resource should be available - and the download should be started. Any exceptions return - False and the download is removed from the download manager. - """ - res = Downloader( - url=download_url, - file=temp_dest, - update_func=update_func, - cancel_event=cancel_event, - ).download() - - if not res.ok: - if not external_task: - TaskManager.remove(task_id) - return res - - if not os.path.isfile(temp_dest): - """Fail if the file is not available in the /temp directory.""" - if not external_task: - TaskManager.remove(task_id) - return Result(False) - - just_downloaded = True - else: - logging.warning( - f"Failed to download [{download_url}] with code: {req_code} != 200" - ) + if not os.path.isfile(temp_dest): if not external_task: TaskManager.remove(task_id) return Result(False) + just_downloaded = True + file_path = os.path.join(Paths.temp, existing_file) if rename and just_downloaded: """Renaming the downloaded file if requested.""" diff --git a/bottles/backend/managers/library.py b/bottles/backend/managers/library.py index a09b4e061f..6a7a4bc430 100644 --- a/bottles/backend/managers/library.py +++ b/bottles/backend/managers/library.py @@ -15,6 +15,7 @@ # along with this program. If not, see . # +import filecmp import os import shutil import threading @@ -163,6 +164,11 @@ def import_thumbnail(source_path, config: Optional[BottleConfig] = None): try: grids_path.mkdir(parents=True, exist_ok=True) + for candidate in grids_path.iterdir(): + if candidate.is_file() and filecmp.cmp( + source_path, candidate, shallow=False + ): + return f"{uri_prefix}{candidate.name}" shutil.copy2(source_path, destination) except OSError as error: logging.warning(f"Could not import library thumbnail: {error}") @@ -200,23 +206,11 @@ def set_thumbnail( managed_prefix = "umu-grid:" if config is None else "grid:" if ( old_thumbnail + and old_thumbnail != thumbnail and old_thumbnail.startswith(managed_prefix) and not thumbnail_is_shared ): - old_filename = old_thumbnail.removeprefix(managed_prefix) - if os.path.basename(old_filename) == old_filename: - if config is None: - old_path = Path(Paths.base) / "umu" / "covers" / old_filename - else: - old_path = ( - Path(ManagerUtils.get_bottle_path(config)) - / "grids" - / old_filename - ) - try: - os.remove(old_path) - except FileNotFoundError: - pass + self.__remove_thumbnail(old_thumbnail, config) return True @@ -260,16 +254,49 @@ def __already_in_library(self, data: dict): return False - def remove_from_library(self, _uuid: str): + @staticmethod + def __remove_thumbnail( + thumbnail: str, config: Optional[BottleConfig] = None + ) -> None: + managed_prefix = "umu-grid:" if config is None else "grid:" + if not thumbnail.startswith(managed_prefix): + return + + filename = thumbnail.removeprefix(managed_prefix) + if os.path.basename(filename) != filename: + return + if config is None: + path = Path(Paths.base) / "umu" / "covers" / filename + else: + path = Path(ManagerUtils.get_bottle_path(config)) / "grids" / filename + try: + os.remove(path) + except FileNotFoundError: + pass + + def remove_from_library( + self, _uuid: str, config: Optional[BottleConfig] = None + ): """ Removes an entry from the library.yml file. """ with self.__lock: self.load_library(silent=True) - if self.__library.get(_uuid): + entry = self.__library.get(_uuid) + if entry: logging.info(f"Removing entry from library: {_uuid}") + thumbnail = entry.get("thumbnail") + thumbnail_is_shared = any( + uuid != _uuid and item.get("thumbnail") == thumbnail + for uuid, item in self.__library.items() + ) del self.__library[_uuid] self.save_library() + if thumbnail and not thumbnail_is_shared: + if entry.get("source") == "umu": + self.__remove_thumbnail(thumbnail) + elif config is not None: + self.__remove_thumbnail(thumbnail, config) return logging.warning(f"Entry not found in library, nothing to remove: {_uuid}") diff --git a/bottles/backend/managers/manager.py b/bottles/backend/managers/manager.py index 2cce64be8e..4d320edd8e 100644 --- a/bottles/backend/managers/manager.py +++ b/bottles/backend/managers/manager.py @@ -943,7 +943,6 @@ def get_managed_wine_runners(self) -> list[str]: runner for runner in self.runners_available if not runner.startswith("sys-") - and not SteamUtils.is_proton(ManagerUtils.get_runner_path(runner)) ] def check_runtimes(self, install_latest: bool = True) -> bool: @@ -2699,12 +2698,15 @@ def __persist_d7vk_config( if not saved.ok: return Result(False, message=saved.message) + config.D7VK = candidate.D7VK + config.Parameters.d7vk = candidate.Parameters.d7vk + config.Update_Date = candidate.Update_Date if candidate.Name in self.local_bottles: - self.local_bottles[candidate.Name] = candidate - if candidate.Environment == "Steam": - self.steam_manager.update_bottle(candidate) - RegistryRuleManager.apply_rules(candidate, trigger="components") - return Result(True, data={"config": candidate}) + self.local_bottles[candidate.Name] = config + if config.Environment == "Steam": + self.steam_manager.update_bottle(config) + RegistryRuleManager.apply_rules(config, trigger="components") + return Result(True, data={"config": config}) def __rollback_d7vk(self, previous: BottleConfig, failure: Result) -> Result: if not self.reconcile_d7vk(previous): diff --git a/bottles/backend/managers/steam.py b/bottles/backend/managers/steam.py index 1d4d080da5..75935bf2c0 100644 --- a/bottles/backend/managers/steam.py +++ b/bottles/backend/managers/steam.py @@ -708,7 +708,6 @@ def launch_app(prefix: str): SignalManager.send(Signals.GShowUri, Result(data=uri)) def add_shortcut(self, program_name: str, program_path: str): - logging.info(f"Adding shortcut for {program_name}") if "FLATPAK_ID" in os.environ: cmd = "flatpak" args = f"run --command=bottles-cli {os.environ['FLATPAK_ID']} run -b {{0}} -p {{1}}" @@ -716,6 +715,25 @@ def add_shortcut(self, program_name: str, program_path: str): cmd = "bottles-cli" args = "run -b {0} -p {1}" + return self.__add_command_shortcut( + program_name, + cmd, + args.format( + shlex.quote(self.config.Name), shlex.quote(program_name) + ), + ManagerUtils.get_bottle_path(self.config), + ManagerUtils.extract_icon(self.config, program_name, program_path), + ) + + def __add_command_shortcut( + self, + program_name: str, + command: str, + arguments: str, + start_dir: str, + icon: str, + ): + logging.info(f"Adding shortcut for {program_name}") if self.userdata_path is None: logging.warning("Userdata path is not set") return Result(False) @@ -723,13 +741,11 @@ def add_shortcut(self, program_name: str, program_path: str): confs = glob(os.path.join(self.userdata_path, "*/config/")) shortcut = { "AppName": program_name, - "Exe": cmd, - "StartDir": ManagerUtils.get_bottle_path(self.config), - "icon": ManagerUtils.extract_icon(self.config, program_name, program_path), + "Exe": command, + "StartDir": start_dir, + "icon": icon, "ShortcutPath": "", - "LaunchOptions": args.format( - shlex.quote(self.config.Name), shlex.quote(program_name) - ), + "LaunchOptions": arguments, "IsHidden": 0, "AllowDesktopConfig": 1, "AllowOverlay": 1, @@ -760,3 +776,22 @@ def add_shortcut(self, program_name: str, program_path: str): logging.info(f"Added shortcut for {program_name}") return Result(True) + + def add_umu_shortcut(self, game): + program = { + "name": game.name, + "executable": game.executable.name, + "umu_game": str(game.id), + } + config = {"Name": f"UMU-{game.id}"} + command = ManagerUtils.get_desktop_entry_exec( + config, program, for_host=True + ) + executable, *arguments = shlex.split(command) + return self.__add_command_shortcut( + game.name, + executable, + shlex.join(arguments), + str(game.executable.parent), + "com.usebottles.bottles", + ) diff --git a/bottles/backend/utils/manager.py b/bottles/backend/utils/manager.py index c9fbaa4c1f..5ecef4ba7b 100644 --- a/bottles/backend/utils/manager.py +++ b/bottles/backend/utils/manager.py @@ -597,6 +597,19 @@ def resolve_file_associations(value) -> tuple[list[str], list[str], list[str]]: @staticmethod def get_desktop_entry_exec(config, program: dict, for_host: bool = False) -> str: + umu_game = program.get("umu_game") + if umu_game: + command = "bottles-cli" + flatpak_id = os.environ.get("FLATPAK_ID") + if for_host and flatpak_id: + command = "flatpak run --command=bottles-cli {}".format( + ManagerUtils.quote_desktop_entry_exec_arg(flatpak_id) + ) + return "{} umu run --game {}".format( + command, + ManagerUtils.quote_desktop_entry_exec_arg(umu_game), + ) + _, mime_types, _ = ManagerUtils.resolve_file_associations( program.get("file_extensions", []) ) diff --git a/bottles/backend/wine/wineboot.py b/bottles/backend/wine/wineboot.py index eff4e2e1ab..92be069b9b 100644 --- a/bottles/backend/wine/wineboot.py +++ b/bottles/backend/wine/wineboot.py @@ -16,7 +16,17 @@ def send_status(self, status: int): if status == -2: return self.nv_stop_all_processes() - states = {-1: "force", 0: "-k", 1: "-r", 2: "-s", 3: "-u", 4: "-i", 5: "-e", 11: "-e -f -k -r", 12: "-e -f -k -s"} + states = { + -1: "force", + 0: "-k", + 1: "-r", + 2: "-s", + 3: "-u", + 4: "-i", + 5: "-e", + 11: "-e -f -k -r", + 12: "-e -f -k -s", + } envs = { "WINEDEBUG": "-all", "DISPLAY": ":3.0", diff --git a/bottles/frontend/main.py b/bottles/frontend/main.py index 4241869ba2..d5c7846421 100644 --- a/bottles/frontend/main.py +++ b/bottles/frontend/main.py @@ -43,7 +43,6 @@ from gi.repository import Adw, Gdk, Gio, GLib, GObject, Gtk # type: ignore from bottles.frontend.utils.gtk import FontScaleManager -from bottles.frontend.views.preferences import PreferencesWindow from bottles.frontend.windows.window import BottlesWindow logging = Logger() @@ -363,8 +362,7 @@ def __refresh(self, action=None, param=None): self.win.manager.update_bottles() def __show_preferences(self, *args): - preferences_window = PreferencesWindow(self.win) - preferences_window.present(self.win) + self.win.show_prefs_view() def __new_bottle(self, *args): self.win.show_add_view() diff --git a/bottles/frontend/ui/library-entry.blp b/bottles/frontend/ui/library-entry.blp index eda0771d1f..fd16ed0eb4 100644 --- a/bottles/frontend/ui/library-entry.blp +++ b/bottles/frontend/ui/library-entry.blp @@ -1,5 +1,27 @@ using Gtk 4.0; +Popover pop_umu_actions { + styles [ + "menu", + ] + + Box { + orientation: vertical; + margin-top: 6; + margin-bottom: 6; + margin-start: 6; + margin-end: 6; + + $GtkModelButton btn_umu_desktop { + text: _("Add Desktop Entry"); + } + + $GtkModelButton btn_umu_steam { + text: _("Add to Steam"); + } + } +} + template $LibraryEntry: Box { orientation: vertical; width-request: 128; @@ -57,39 +79,28 @@ template $LibraryEntry: Box { } [overlay] - Revealer revealer_run { - reveal-child: false; - transition-type: crossfade; - valign: center; - - Box { - valign: center; - halign: center; + Button btn_run { + hexpand: true; + vexpand: true; + tooltip-text: _("Launch"); - Button btn_run { - valign: center; - halign: center; - label: _("Launch"); + styles [ + "flat", + "library-entry-launch", + ] + } - styles [ - "osd", - "pill", - ] - } + [overlay] + Button btn_launch_steam { + visible: false; + hexpand: true; + vexpand: true; + tooltip-text: _("Launch with Steam"); - [overlay] - Button btn_launch_steam { - valign: center; - halign: center; - visible: false; - label: _("Launch with Steam"); - - styles [ - "osd", - "pill", - ] - } - } + styles [ + "flat", + "library-entry-launch", + ] } [overlay] @@ -143,6 +154,18 @@ template $LibraryEntry: Box { [end] Box { + MenuButton btn_umu_actions { + visible: false; + halign: center; + icon-name: "view-more-symbolic"; + tooltip-text: _("More Actions"); + popover: pop_umu_actions; + + styles [ + "flat" + ] + } + Button btn_settings { visible: false; halign: center; @@ -191,5 +214,6 @@ template $LibraryEntry: Box { styles [ "card", + "library-entry", ] } diff --git a/bottles/frontend/ui/style.css b/bottles/frontend/ui/style.css index 5bc18c5e30..6fb8a9794b 100644 --- a/bottles/frontend/ui/style.css +++ b/bottles/frontend/ui/style.css @@ -141,8 +141,27 @@ entry.heading_1 { /* Library */ .library-entry { border: 1px solid alpha(currentColor, .07); - border-radius: 4px; + border-radius: 12px; background-color: alpha(currentColor, .04); + box-shadow: none; +} + +.library-entry:hover { + border-color: alpha(@accent_color, .8); + box-shadow: none; +} + +.library-entry-launch { + border-radius: 12px; + background: transparent; + box-shadow: none; + padding: 0; +} + +.library-entry-launch:hover, +.library-entry-launch:active { + background: transparent; + box-shadow: none; } .library-entry-details { diff --git a/bottles/frontend/views/bottle_details.py b/bottles/frontend/views/bottle_details.py index 030534cec3..21458ce977 100644 --- a/bottles/frontend/views/bottle_details.py +++ b/bottles/frontend/views/bottle_details.py @@ -1006,18 +1006,9 @@ def handle_response(_widget, response_id): dialog.set_response_appearance("ok", Adw.ResponseAppearance.DESTRUCTIVE) dialog.connect("response", handle_response) dialog.present() - elif status == 1: - """ - Intercepting 'wineboot -r' (state 1) to send 'wineboot -e -f -k -r' (state 11) instead. - Attempts to gracefully close applications before executing the Wine reboot - """ - RunAsync(wineboot.send_status, callback=reset, status=11) - elif status == 2: - """ - Intercepting 'wineboot -s' (state 2) to send 'wineboot -e -f -k -s' (state 12) instead. - Attempts to gracefully close applications before executing the Wine shutdown - """ - RunAsync(wineboot.send_status, callback=reset, status=12) + elif status in (1, 2): + graceful_status = {1: 11, 2: 12}[status] + RunAsync(wineboot.send_status, callback=reset, status=graceful_status) def __set_steam_rules(self): status = False if self.config.Environment == "Steam" else True diff --git a/bottles/frontend/views/library.py b/bottles/frontend/views/library.py index 374e9a58fc..2e71c77676 100644 --- a/bottles/frontend/views/library.py +++ b/bottles/frontend/views/library.py @@ -56,15 +56,18 @@ def update(self): while self.main_flow.get_first_child() is not None: self.main_flow.remove(self.main_flow.get_first_child()) - self.items_per_line = len(entries) + 1 + entry_count = 0 for u, e in entries.items(): # We suppress exceptions so that it doesn't continue if the init fails with contextlib.suppress(Exception): entry = LibraryEntry(self, u, e) self.main_flow.append(entry) + entry_count += 1 - self.main_flow.append(LibraryAddEntry(self)) + if entry_count == 0: + self.main_flow.append(LibraryAddEntry(self)) + self.items_per_line = max(entry_count, 1) self.__search() def __search(self, *_args): @@ -116,9 +119,11 @@ def show_bottle_programs(self, *_args): ) def remove_entry(self, entry): + previous_items_per_line = self.items_per_line + @GtkUtils.run_in_main_loop def undo_callback(*args): - self.items_per_line += 1 + self.items_per_line = previous_items_per_line entry.show() @GtkUtils.run_in_main_loop @@ -126,7 +131,7 @@ def dismissed_callback(*args): self.__delete_entry(entry) entry.hide() - self.items_per_line -= 1 + self.items_per_line = max(self.items_per_line - 1, 1) self.window.show_toast( message=_('"{0}" removed from the library.').format(entry.name), timeout=5, @@ -137,7 +142,8 @@ def dismissed_callback(*args): def __delete_entry(self, entry): library_manager = LibraryManager() - library_manager.remove_from_library(entry.uuid) + library_manager.remove_from_library(entry.uuid, entry.config) + self.update() def go_back(self, widget=False): self.window.main_leaf.navigate(Adw.NavigationDirection.BACK) diff --git a/bottles/frontend/views/list.py b/bottles/frontend/views/list.py index 689d023f44..6d3bd3c63c 100644 --- a/bottles/frontend/views/list.py +++ b/bottles/frontend/views/list.py @@ -301,14 +301,9 @@ def update_bottles_list(self, *args, refresh_updates=True) -> None: self.update_component_updates_banner() def __build_umu_empty_row(self): - launcher_available = self.window.manager.get_umu_installation() is not None row = Adw.ActionRow( title=_("No UMU Prefixes Yet"), - subtitle=( - _("Install a Windows game to create one.") - if launcher_available - else _("Configure the UMU launcher in Preferences first.") - ), + subtitle=_("Install a Windows game to create one."), ) actions = Gtk.Box(spacing=6, valign=Gtk.Align.CENTER) diff --git a/bottles/frontend/views/preferences.py b/bottles/frontend/views/preferences.py index d1e5fa10ae..623ec54006 100644 --- a/bottles/frontend/views/preferences.py +++ b/bottles/frontend/views/preferences.py @@ -43,7 +43,6 @@ @Gtk.Template(resource_path="/com/usebottles/bottles/preferences.ui") class PreferencesWindow(Adw.PreferencesDialog): __gtype_name__ = "PreferencesWindow" - __registry = [] # region Widgets installers_stack = Gtk.Template.Child() @@ -166,6 +165,7 @@ def __init__(self, window, **kwargs): row.connect("changed", self.__on_personal_repo_changed, repo_name) self.__cache_registry = [] + self.__registry = [] # bind widgets self.settings.bind( diff --git a/bottles/frontend/widgets/library.py b/bottles/frontend/widgets/library.py index e8c425ac98..32a47e426b 100644 --- a/bottles/frontend/widgets/library.py +++ b/bottles/frontend/widgets/library.py @@ -24,6 +24,7 @@ from bottles.backend.managers.thumbnail import ThumbnailManager from bottles.backend.models.result import Result from bottles.backend.umu import UmuRepositoryError +from bottles.backend.utils.manager import ManagerUtils from bottles.backend.utils.threading import RunAsync from bottles.backend.wine.executor import WineExecutor from bottles.backend.wine.winedbg import WineDbg @@ -64,13 +65,15 @@ class LibraryEntry(Gtk.Box): btn_launch_steam = Gtk.Template.Child() btn_cover = Gtk.Template.Child() btn_settings = Gtk.Template.Child() + btn_umu_actions = Gtk.Template.Child() + btn_umu_desktop = Gtk.Template.Child() + btn_umu_steam = Gtk.Template.Child() btn_remove = Gtk.Template.Child() label_name = Gtk.Template.Child() label_bottle = Gtk.Template.Child() label_no_cover = Gtk.Template.Child() label_source = Gtk.Template.Child() img_cover = Gtk.Template.Child() - revealer_run = Gtk.Template.Child() revealer_details = Gtk.Template.Child() overlay = Gtk.Template.Child() @@ -133,6 +136,8 @@ def __init__(self, library, uuid, entry, *args, **kwargs): self.label_bottle.set_text(f"UMU / {detail}") self.label_source.set_visible(True) self.btn_settings.set_visible(True) + self.btn_umu_actions.set_visible(True) + self.btn_umu_actions.set_sensitive(self.game.state == "ready") executor = self.manager.get_umu_executor(for_launch=False) if executor is not None and executor.is_running(self.game): self.btn_remove.set_visible(False) @@ -196,6 +201,8 @@ def cover_downloaded(result, error=False): self.btn_launch_steam.connect("clicked", self.run_steam) self.btn_cover.connect("clicked", self.__choose_cover) self.btn_settings.connect("clicked", self.__show_settings) + self.btn_umu_desktop.connect("clicked", self.__add_umu_desktop_entry) + self.btn_umu_steam.connect("clicked", self.__add_umu_steam_shortcut) self.btn_stop.connect("clicked", self.stop_process) self.btn_remove.connect("clicked", self.__remove_entry) @@ -240,7 +247,7 @@ def __get_program(self): def __remove_from_library(self): library_manager = LibraryManager() - library_manager.remove_from_library(self.uuid) + library_manager.remove_from_library(self.uuid, getattr(self, "config", None)) def __handle_initialization_failure(self, message: str): logging.warning(message, jn=False) @@ -292,6 +299,60 @@ def __remove_entry(self, *args): def __show_settings(self, *_args): self.window.show_umu_game_settings(str(self.game.id)) + def __umu_launcher_data(self): + return ( + {"Name": f"UMU-{self.game.id}"}, + { + "name": self.game.name, + "executable": self.game.executable.name, + "umu_game": str(self.game.id), + }, + ) + + def __add_umu_desktop_entry(self, *_args): + config, program = self.__umu_launcher_data() + self.btn_umu_desktop.set_sensitive(False) + + def complete(result): + self.btn_umu_desktop.set_sensitive(True) + if result.ok: + self.window.show_toast( + _('Desktop Entry created for "{0}"').format(self.game.name) + ) + else: + self.window.show_toast( + _('Could not create a Desktop Entry for "{0}"').format( + self.game.name + ) + ) + + ManagerUtils.create_desktop_entry( + config, + program, + skip_icon=True, + callback=complete, + ) + + def __add_umu_steam_shortcut(self, *_args): + self.btn_umu_steam.set_sensitive(False) + + def complete(result, error=False): + self.btn_umu_steam.set_sensitive(True) + if not error and result and result.ok: + self.window.show_toast( + _('Added "{0}" to Steam').format(self.game.name) + ) + else: + self.window.show_toast( + _('Could not add "{0}" to Steam').format(self.game.name) + ) + + RunAsync( + self.manager.steam_manager.add_umu_shortcut, + callback=complete, + game=self.game, + ) + def __choose_cover(self, *_args): def set_cover(dialog, result): try: @@ -420,11 +481,9 @@ def complete(stopped, error=False): self.__reset_buttons(True) def __on_motion_enter(self, *args): - self.revealer_run.set_reveal_child(True) self.revealer_details.set_reveal_child(True) def __on_motion_leave(self, *args): - self.revealer_run.set_reveal_child(False) self.revealer_details.set_reveal_child(False) # hide() and show() are essentialy workarounds to avoid keeping diff --git a/bottles/frontend/windows/window.py b/bottles/frontend/windows/window.py index b106d248e3..cb5cbb6d14 100644 --- a/bottles/frontend/windows/window.py +++ b/bottles/frontend/windows/window.py @@ -99,6 +99,7 @@ def __init__(self, arg_bottle, **kwargs): ) self._show_funding = False self._funding_dialog = None + self._preferences_window = None show_funding_setting = self.settings.get_boolean("show-funding") dismissed = self.data_mgr.get(UserDataKeys.FundingDismissed, False) @@ -445,7 +446,6 @@ def get_manager(): g_settings=self.settings, check_connection=self.utils_conn.aborted_connections == 0, ) - mng.get_umu_installation() return mng self.show_loading_view() @@ -584,7 +584,10 @@ def show_importer_view(self, widget=False): self.main_leaf.set_visible_child(self.page_importer) def show_prefs_view(self, widget=False, view=0, page=None): - preferences_window = PreferencesWindow(self) + if self._preferences_window is None: + self._preferences_window = PreferencesWindow(self) + + preferences_window = self._preferences_window if page: preferences_window.set_visible_page_name(page) elif view: diff --git a/bottles/tests/backend/manager/test_library.py b/bottles/tests/backend/manager/test_library.py index 3dfffdade5..317ee7996c 100644 --- a/bottles/tests/backend/manager/test_library.py +++ b/bottles/tests/backend/manager/test_library.py @@ -141,7 +141,7 @@ def test_set_thumbnail_replaces_managed_cover(tmp_path, library_manager): first_cover = tmp_path / "first.png" _write_cover(first_cover) second_cover = tmp_path / "second.jpg" - _write_cover(second_cover) + second_cover.write_bytes(PNG_DATA + b"\n") manager.add_to_library( { @@ -157,10 +157,28 @@ def test_set_thumbnail_replaces_managed_cover(tmp_path, library_manager): entry = manager.get_library()[entry_uuid] new_cover = bottle_path / "grids" / entry["thumbnail"].removeprefix("grid:") - assert new_cover.read_bytes() == PNG_DATA + assert new_cover.read_bytes() == PNG_DATA + b"\n" assert not old_cover.exists() +def test_set_thumbnail_reuses_identical_managed_cover(tmp_path, library_manager): + manager, bottle_path = library_manager + program_folder = tmp_path / "program" + program_folder.mkdir() + first_cover = tmp_path / "first.png" + second_cover = tmp_path / "second.png" + _write_cover(first_cover) + _write_cover(second_cover) + config = _config(program_folder) + thumbnail = manager.import_thumbnail(first_cover, config) + entry_uuid = manager.add_to_library({**_entry(), "thumbnail": thumbnail}, config) + + assert manager.set_thumbnail(entry_uuid, second_cover, config) + + assert manager.get_library()[entry_uuid]["thumbnail"] == thumbnail + assert (bottle_path / "grids" / thumbnail.removeprefix("grid:")).exists() + + def test_set_thumbnail_keeps_cover_used_by_another_entry(tmp_path, library_manager): manager, bottle_path = library_manager program_folder = tmp_path / "program" @@ -183,6 +201,40 @@ def test_set_thumbnail_keeps_cover_used_by_another_entry(tmp_path, library_manag assert old_cover.exists() +def test_remove_from_library_removes_managed_cover(tmp_path, library_manager): + manager, bottle_path = library_manager + program_folder = tmp_path / "program" + program_folder.mkdir() + cover = tmp_path / "cover.png" + _write_cover(cover) + config = _config(program_folder) + thumbnail = manager.import_thumbnail(cover, config) + entry_uuid = manager.add_to_library({**_entry(), "thumbnail": thumbnail}, config) + stored_cover = bottle_path / "grids" / thumbnail.removeprefix("grid:") + + manager.remove_from_library(entry_uuid, config) + + assert entry_uuid not in manager.get_library() + assert not stored_cover.exists() + + +def test_import_thumbnail_reuses_identical_cover(tmp_path, library_manager): + manager, bottle_path = library_manager + program_folder = tmp_path / "program" + program_folder.mkdir() + first = tmp_path / "first.png" + second = tmp_path / "second.png" + _write_cover(first) + _write_cover(second) + config = _config(program_folder) + + first_thumbnail = manager.import_thumbnail(first, config) + second_thumbnail = manager.import_thumbnail(second, config) + + assert second_thumbnail == first_thumbnail + assert len(list((bottle_path / "grids").iterdir())) == 1 + + def test_umu_entry_does_not_require_bottle_config( monkeypatch, tmp_path, library_manager ): diff --git a/bottles/tests/backend/manager/test_manager.py b/bottles/tests/backend/manager/test_manager.py index f4aad2bc3f..c8841f4e6b 100644 --- a/bottles/tests/backend/manager/test_manager.py +++ b/bottles/tests/backend/manager/test_manager.py @@ -132,6 +132,13 @@ def test_managed_runner_takes_precedence_over_steam_copy(tmp_path, monkeypatch): assert ManagerUtils.get_runner_path("GE-Proton10-4") == str(managed) +def test_managed_proton_runner_satisfies_runner_setup_check(): + manager = object.__new__(Manager) + manager.runners_available = ["GE-Proton10-4", "sys-wine-11.0"] + + assert manager.get_managed_wine_runners() == ["GE-Proton10-4"] + + def test_manager_cli_checks_connection_when_online(mocker): check_connection = mocker.patch.object( ConnectionUtils, diff --git a/bottles/tests/backend/manager/test_steam.py b/bottles/tests/backend/manager/test_steam.py index 70e6d4b2a0..aed9fabe05 100644 --- a/bottles/tests/backend/manager/test_steam.py +++ b/bottles/tests/backend/manager/test_steam.py @@ -1,5 +1,7 @@ import shlex from pathlib import Path +from types import SimpleNamespace +from uuid import UUID import pytest @@ -198,6 +200,35 @@ def test_steam_shortcut_quotes_apostrophes( ] +def test_umu_shortcut_uses_umu_cli(tmp_path, monkeypatch): + config_dir = tmp_path / "userdata" / "123" / "config" + config_dir.mkdir(parents=True) + game_id = UUID("e33f87f0-648e-44d2-bb73-78c9f60f77cf") + game = SimpleNamespace( + id=game_id, + name="Test Game", + executable=tmp_path / "prefix" / "game.exe", + ) + manager = object.__new__(SteamManager) + manager.userdata_path = str(tmp_path / "userdata") + monkeypatch.delenv("FLATPAK_ID", raising=False) + + result = manager.add_umu_shortcut(game) + + with open(config_dir / "shortcuts.vdf", "rb") as shortcuts_file: + shortcut = vdf.binary_loads(shortcuts_file.read())["shortcuts"]["0"] + + assert result.ok + assert shortcut["Exe"] == "bottles-cli" + assert shlex.split(shortcut["LaunchOptions"]) == [ + "umu", + "run", + "--game", + str(game_id), + ] + assert shortcut["StartDir"] == str(game.executable.parent) + + def test_list_compatibility_tools_keeps_only_valid_proton(tmp_path, monkeypatch): flatpak_steam = ( tmp_path / ".var" / "app" / "com.valvesoftware.Steam" / "data" / "Steam" diff --git a/bottles/tests/backend/test_d7vk.py b/bottles/tests/backend/test_d7vk.py index 51298c0568..c2804dd3c6 100644 --- a/bottles/tests/backend/test_d7vk.py +++ b/bottles/tests/backend/test_d7vk.py @@ -509,8 +509,9 @@ def test_set_d7vk_persists_one_candidate_config(monkeypatch, tmp_path): assert dumped[0]["D7VK"] == "d7vk-v2.0" assert dumped[0]["Parameters"]["d7vk"] is True assert result.data["config"] is manager.local_bottles[config.Name] - assert config.D7VK == "" - assert config.Parameters.d7vk is False + assert result.data["config"] is config + assert config.D7VK == "d7vk-v2.0" + assert config.Parameters.d7vk is True def test_reconcile_d7vk_reverts_an_interrupted_enable(monkeypatch, tmp_path): diff --git a/bottles/tests/backend/test_network_timeouts.py b/bottles/tests/backend/test_network_timeouts.py index e8674f641a..850c2fc3b3 100644 --- a/bottles/tests/backend/test_network_timeouts.py +++ b/bottles/tests/backend/test_network_timeouts.py @@ -1,3 +1,4 @@ +from pathlib import Path from types import SimpleNamespace import pycurl @@ -101,9 +102,18 @@ def timeout(): assert curl.closed is True -def test_component_probe_has_timeouts(monkeypatch, tmp_path): - curl = FakeCurl(response_code=503) - monkeypatch.setattr(component_module.pycurl, "Curl", lambda: curl) +def test_component_download_uses_stream_downloader(monkeypatch, tmp_path): + request = {} + + class DownloadStub: + def __init__(self, **kwargs): + request.update(kwargs) + + def download(self): + Path(request["file"]).write_bytes(b"runner") + return Result(True) + + monkeypatch.setattr(component_module, "Downloader", DownloadStub) monkeypatch.setattr(Paths, "temp", str(tmp_path)) component = object.__new__(ComponentManager) @@ -115,9 +125,8 @@ def test_component_probe_has_timeouts(monkeypatch, tmp_path): "runner.tar.xz", ) - assert result.ok is False - assert curl.options[pycurl.CONNECTTIMEOUT] == 10 - assert curl.options[pycurl.TIMEOUT] == 30 + assert result.ok is True + assert request["url"] == "https://example.test/runner.tar.xz" def test_stream_download_has_timeouts(monkeypatch, tmp_path): @@ -126,7 +135,9 @@ def test_stream_download_has_timeouts(monkeypatch, tmp_path): def get(url, **kwargs): request["url"] = url request.update(kwargs) - return SimpleNamespace(headers={}, content=b"runner") + return SimpleNamespace( + headers={}, content=b"runner", raise_for_status=lambda: None + ) monkeypatch.setattr(downloader_module.requests, "get", get) @@ -143,6 +154,10 @@ def test_stream_timeout_removes_partial_download(monkeypatch, tmp_path): class Response: headers = {"content-length": "8"} + @staticmethod + def raise_for_status(): + return None + @staticmethod def iter_content(_size): yield b"half" diff --git a/bottles/tests/backend/utils/test_manager.py b/bottles/tests/backend/utils/test_manager.py index 43786e98e4..6d74957208 100644 --- a/bottles/tests/backend/utils/test_manager.py +++ b/bottles/tests/backend/utils/test_manager.py @@ -656,6 +656,36 @@ def test_desktop_entry_exec_uses_flatpak_file_forwarding_for_host(monkeypatch): ) +def test_umu_desktop_entry_exec_uses_game_id(monkeypatch): + monkeypatch.delenv("FLATPAK_ID", raising=False) + config = {"Name": "UMU-test"} + program = { + "name": "Test Game", + "executable": "game.exe", + "umu_game": "e33f87f0-648e-44d2-bb73-78c9f60f77cf", + } + + assert ManagerUtils.get_desktop_entry_exec(config, program, for_host=True) == ( + 'bottles-cli umu run --game "e33f87f0-648e-44d2-bb73-78c9f60f77cf"' + ) + + +def test_umu_desktop_entry_exec_uses_flatpak_cli(monkeypatch): + monkeypatch.setenv("FLATPAK_ID", "com.usebottles.bottles") + config = {"Name": "UMU-test"} + program = { + "name": "Test Game", + "executable": "game.exe", + "umu_game": "e33f87f0-648e-44d2-bb73-78c9f60f77cf", + } + + assert ManagerUtils.get_desktop_entry_exec(config, program, for_host=True) == ( + "flatpak run --command=bottles-cli " + '"com.usebottles.bottles" umu run --game ' + '"e33f87f0-648e-44d2-bb73-78c9f60f77cf"' + ) + + def test_has_desktop_entry_detects_dynamic_launcher(monkeypatch): config = BottleConfig(Name="Documents") program = {"name": "Document Editor", "executable": "editor.exe"} diff --git a/bottles/tests/backend/wine/test_wineboot.py b/bottles/tests/backend/wine/test_wineboot.py index 4ecca200d5..f5c9fd43bc 100644 --- a/bottles/tests/backend/wine/test_wineboot.py +++ b/bottles/tests/backend/wine/test_wineboot.py @@ -26,6 +26,8 @@ def run(self): wineboot.update() wineboot.restart() wineboot.shutdown() + wineboot.send_status(11) + wineboot.send_status(12) wineboot.force() wineboot.kill() @@ -34,6 +36,8 @@ def run(self): "wineboot -u /nogui", "wineboot -r /nogui", "wineboot -s /nogui", + "wineboot -e -f -k -r /nogui", + "wineboot -e -f -k -s /nogui", "wineboot force /nogui", "wineboot -k /nogui", ] @@ -44,6 +48,8 @@ def run(self): None, None, None, + None, + None, ] assert all( launch["environment"]["WINEDLLOVERRIDES"] == "winemenubuilder=d" diff --git a/bottles/tests/frontend/test_library_view.py b/bottles/tests/frontend/test_library_view.py new file mode 100644 index 0000000000..f16bfd3e5d --- /dev/null +++ b/bottles/tests/frontend/test_library_view.py @@ -0,0 +1,39 @@ +# ruff: noqa: E402 + +import os +from pathlib import Path +from types import SimpleNamespace + +import pytest +from gi.repository import Gio + +resource_path = Path( + os.environ.get("BOTTLES_TEST_RESOURCE", "/app/share/bottles/bottles.gresource") +) +if not resource_path.exists(): + pytest.skip( + "Library frontend tests require the Bottles resource bundle", + allow_module_level=True, + ) +Gio.resources_register(Gio.Resource.load(str(resource_path))) + +from bottles.frontend.views import library as library_module +from bottles.frontend.views.library import LibraryView + + +def test_delete_entry_refreshes_library(monkeypatch): + removed = [] + updates = [] + manager = SimpleNamespace( + remove_from_library=lambda entry_uuid, config: removed.append( + (entry_uuid, config) + ) + ) + monkeypatch.setattr(library_module, "LibraryManager", lambda: manager) + view = SimpleNamespace(update=lambda: updates.append(True)) + entry = SimpleNamespace(uuid="entry-id", config="config") + + LibraryView._LibraryView__delete_entry(view, entry) + + assert removed == [("entry-id", "config")] + assert updates == [True] diff --git a/bottles/tests/frontend/test_preferences_window.py b/bottles/tests/frontend/test_preferences_window.py new file mode 100644 index 0000000000..c5150a28e3 --- /dev/null +++ b/bottles/tests/frontend/test_preferences_window.py @@ -0,0 +1,50 @@ +# ruff: noqa: E402 + +import os +from pathlib import Path +from types import SimpleNamespace + +import pytest +from gi.repository import Gio + +resource_path = Path( + os.environ.get("BOTTLES_TEST_RESOURCE", "/app/share/bottles/bottles.gresource") +) +if not resource_path.exists(): + pytest.skip( + "Preferences frontend tests require the Bottles resource bundle", + allow_module_level=True, + ) +Gio.resources_register(Gio.Resource.load(str(resource_path))) + +from bottles.frontend.windows import window as window_module +from bottles.frontend.windows.window import BottlesWindow + + +class PreferencesWindowStub: + instances = [] + + def __init__(self, window): + self.window = window + self.page_name = None + self.presented_with = None + self.instances.append(self) + + def set_visible_page_name(self, page): + self.page_name = page + + def present(self, window): + self.presented_with = window + + +def test_preferences_window_is_reused(monkeypatch): + PreferencesWindowStub.instances = [] + monkeypatch.setattr(window_module, "PreferencesWindow", PreferencesWindowStub) + window = SimpleNamespace(_preferences_window=None) + + BottlesWindow.show_prefs_view(window) + BottlesWindow.show_prefs_view(window, page="umu") + + assert len(PreferencesWindowStub.instances) == 1 + assert window._preferences_window.page_name == "umu" + assert window._preferences_window.presented_with is window diff --git a/bottles/tests/frontend/test_umu_ui.py b/bottles/tests/frontend/test_umu_ui.py index 2ccd9cf491..172696976d 100644 --- a/bottles/tests/frontend/test_umu_ui.py +++ b/bottles/tests/frontend/test_umu_ui.py @@ -36,9 +36,13 @@ def _descendants(widget): def test_empty_umu_row_only_offers_install(): calls = [] + + def unexpected_probe(): + pytest.fail("the UMU launcher must not be probed while building the list") + view = SimpleNamespace( window=SimpleNamespace( - manager=SimpleNamespace(get_umu_installation=lambda: object()), + manager=SimpleNamespace(get_umu_installation=unexpected_probe), show_umu_search=lambda *args: calls.append(args), ) ) diff --git a/cpak.json b/cpak.json index e4a8b85840..f4967f78a2 100644 --- a/cpak.json +++ b/cpak.json @@ -6,7 +6,7 @@ "version": "66.7", "image": "ghcr.io/bottlesdevs/bottles:main", "image_ref": "source", - "binaries": ["/usr/bin/bottles"], + "binaries": ["/usr/bin/bottles", "/usr/bin/bottles-cli"], "desktop_entries": ["/usr/share/applications/com.usebottles.bottles.desktop"], "dependencies": [ {