From aaa07e54e80449bca1f284ae26020541b9d33d56 Mon Sep 17 00:00:00 2001 From: g33z Date: Sat, 18 Apr 2026 23:59:32 +0200 Subject: [PATCH 1/3] fix[closes #4482]: [Bug]: KDE/Wayland Desktop Entry creation failed --- bottles/backend/utils/manager.py | 115 +++++++++++++++++++++++-------- 1 file changed, 86 insertions(+), 29 deletions(-) diff --git a/bottles/backend/utils/manager.py b/bottles/backend/utils/manager.py index 41c6143546..467eec4c4e 100644 --- a/bottles/backend/utils/manager.py +++ b/bottles/backend/utils/manager.py @@ -239,18 +239,78 @@ def create_desktop_entry( elif custom_icon: icon = custom_icon - def prepare_install_cb (self, result): - ret = portal.dynamic_launcher_prepare_install_finish(result) - id = f"{config.get('Name')}.{program.get('name')}" - sum_type = GLib.ChecksumType.SHA1 - exec = "bottles-cli run -p {} -b {} -- %u".format( - shlex.quote(program.get('name')), shlex.quote(config.get('Name')) + def create_manual_fallback(icon_path, exec_cmd): + """Create desktop entry manually when portal is unavailable.""" + safe_name = "".join( + [c for c in program.get("name") if c.isalnum() or c in ("-", "_")] + ) + filename = f"bottles-{config.get('Name')}-{safe_name}.desktop" + content = ( + f"[Desktop Entry]\n" + f"Exec={exec_cmd}\n" + f"Type=Application\n" + f"Terminal=false\n" + f"Categories=Application;\n" + f"Comment=Launch {program.get('name')} using Bottles.\n" + f"StartupWMClass={program.get('name')}\n" + f"Name={program.get('name')}\n" + f"Icon={icon_path}\n" ) + + # Write to application menu + apps_dir = os.path.expanduser("~/.local/share/applications") + os.makedirs(apps_dir, exist_ok=True) + apps_path = os.path.join(apps_dir, filename) + try: + with open(apps_path, "w") as f: + f.write(content) + logging.info(f"Desktop entry created at {apps_path}") + except Exception as e: + logging.error(f"Failed to write desktop entry to applications: {e}") + + # Write to desktop surface + desktop_dir = GLib.get_user_special_dir( + GLib.UserDirectory.DIRECTORY_DESKTOP + ) + if desktop_dir: + desktop_path = os.path.join(desktop_dir, filename) + try: + with open(desktop_path, "w") as f: + f.write(content) + # Make executable so KDE/GNOME will run it + os.chmod(desktop_path, 0o755) + logging.info(f"Desktop shortcut created at {desktop_path}") + except Exception as e: + logging.error(f"Failed to write desktop shortcut: {e}") + + SignalManager.send(Signals.DesktopEntryCreated) + + def prepare_install_cb(self, result): + exec_cmd = "bottles-cli run -p {} -b {} -- %u".format( + shlex.quote(program.get("name")), shlex.quote(config.get("Name")) + ) + + # Handle portal preparation failure (e.g., KDE's broken implementation) + try: + ret = portal.dynamic_launcher_prepare_install_finish(result) + if ret is None: + raise GLib.Error("Portal request was rejected or cancelled") + except GLib.Error as e: + logging.warning( + f"Dynamic Launcher portal preparation failed: {e}. " + "Falling back to manual creation." + ) + create_manual_fallback(icon, exec_cmd) + return + + launcher_id = f"{config.get('Name')}.{program.get('name')}" + sum_type = GLib.ChecksumType.SHA1 try: portal.dynamic_launcher_install( ret["token"], "{}.App_{}.desktop".format( - APP_ID, GLib.compute_checksum_for_string(sum_type, id, -1) + APP_ID, + GLib.compute_checksum_for_string(sum_type, launcher_id, -1), ), """[Desktop Entry] Exec={} @@ -259,25 +319,16 @@ def prepare_install_cb (self, result): Categories=Application; Comment=Launch {} using Bottles. StartupWMClass={}""".format( - exec, program.get("name"), program.get("name") - ) + exec_cmd, program.get("name"), program.get("name") + ), ) + SignalManager.send(Signals.DesktopEntryCreated) except GLib.Error as e: - logging.warning(f"Failed to use Dynamic Launcher portal: {e}. Falling back to manual creation.") - desktop_dir = os.path.expanduser("~/.local/share/applications") - os.makedirs(desktop_dir, exist_ok=True) - safe_name = "".join([c for c in program.get("name") if c.isalnum() or c in ("-", "_")]) - filename = f"bottles-{config.get('Name')}-{safe_name}.desktop" - filepath = os.path.join(desktop_dir, filename) - content = f"[Desktop Entry]\nExec={exec}\nType=Application\nTerminal=false\nCategories=Application;\nComment=Launch {program.get('name')} using Bottles.\nStartupWMClass={program.get('name')}\nName={program.get('name')}\nIcon={icon}\n" - try: - with open(filepath, "w") as f: - f.write(content) - logging.info(f"Fallback desktop entry created at {filepath}. If it doesn't show up, you might need to give Bottles the --filesystem=xdg-data/applications permission.") - except Exception as e: - logging.error(f"Failed to write fallback desktop entry: {e}") - - SignalManager.send(Signals.DesktopEntryCreated) + logging.warning( + f"Dynamic Launcher portal install failed: {e}. " + "Falling back to manual creation." + ) + create_manual_fallback(icon, exec_cmd) if icon != "com.usebottles.bottles-program" and not os.path.exists(icon): logging.warning(f"Icon file not found: {icon}. Falling back to default.") @@ -291,11 +342,17 @@ def prepare_install_cb (self, result): else: _icon = Gio.File.new_for_path(icon) icon_v = Gio.BytesIcon.new(_icon.load_bytes()[0]).serialize() - portal.dynamic_launcher_prepare_install(None, - program.get("name"), icon_v, - Xdp.LauncherType.APPLICATION, - None, True, False, None, - prepare_install_cb) + portal.dynamic_launcher_prepare_install( + None, + program.get("name"), + icon_v, + Xdp.LauncherType.APPLICATION, + None, + True, + False, + None, + prepare_install_cb, + ) @staticmethod def browse_wineprefix(wineprefix: dict): From 442b00fa2e2a93bb32c5b06867cef97bd72478ae Mon Sep 17 00:00:00 2001 From: g33z Date: Sun, 19 Apr 2026 00:36:23 +0200 Subject: [PATCH 2/3] fix[closes #4482]: [Bug]: KDE/Wayland Desktop Entry creation failed --- README.md | 6 +++--- build-aux/com.usebottles.bottles.Devel.json | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 40757f2e56..87f264795a 100644 --- a/README.md +++ b/README.md @@ -53,9 +53,9 @@ There are two methods to build Bottles. The first and longer method is using `or ### org.flatpak.Builder 1. Install [`org.flatpak.Builder`](https://github.com/flathub/org.flatpak.Builder) from Flathub -1. Clone `https://github.com/bottlesdevs/Bottles.git` (or your fork) -1. Run `flatpak run org.flatpak.Builder --install --install-deps-from=flathub --default-branch=master --force-clean build-dir build-aux/com.usebottles.bottles.Devel.json` in the terminal from the root of the repository (use `--user` if necessary) -1. Run `flatpak run com.usebottles.bottles.Devel` to launch it +2. Clone `https://github.com/bottlesdevs/Bottles.git` (or your fork) +3. Run `flatpak run org.flatpak.Builder --install --install-deps-from=flathub --default-branch=master --force-clean build-dir build-aux/com.usebottles.bottles.Devel.json` in the terminal from the root of the repository (use `--user` if necessary) +4. Run `flatpak run com.usebottles.bottles.Devel` to launch it ### Meson diff --git a/build-aux/com.usebottles.bottles.Devel.json b/build-aux/com.usebottles.bottles.Devel.json index d816a5191a..cbedd92a3a 100644 --- a/build-aux/com.usebottles.bottles.Devel.json +++ b/build-aux/com.usebottles.bottles.Devel.json @@ -16,6 +16,8 @@ "--socket=wayland", "--socket=pulseaudio", "--device=all", + "--filesystem=xdg-data/applications:create", + "--filesystem=xdg-desktop:create", "--system-talk-name=org.freedesktop.UDisks2", "--env=LD_LIBRARY_PATH=/app/lib:/app/lib/i386-linux-gnu", "--env=PATH=/app/bin:/app/utils/bin:/usr/bin:/usr/lib/extensions/vulkan/MangoHud/bin/:/usr/bin:/usr/lib/extensions/vulkan/OBSVkCapture/bin/:/usr/lib/extensions/vulkan/gamescope/bin/", From 623665732baceee19849a928ba368979672e8448 Mon Sep 17 00:00:00 2001 From: g33z Date: Sun, 19 Apr 2026 12:21:57 +0200 Subject: [PATCH 3/3] fix[closes #3970]: [Bug]: Renaming doesn't update desktop entry --- bottles/backend/utils/manager.py | 220 ++++++++++++++++++- bottles/frontend/views/bottle_preferences.py | 6 + bottles/frontend/widgets/program.py | 12 +- 3 files changed, 231 insertions(+), 7 deletions(-) diff --git a/bottles/backend/utils/manager.py b/bottles/backend/utils/manager.py index 467eec4c4e..3f3d5f0613 100644 --- a/bottles/backend/utils/manager.py +++ b/bottles/backend/utils/manager.py @@ -15,6 +15,7 @@ # along with this program. If not, see . # import os +import re import shlex import shutil from gettext import gettext as _ @@ -241,10 +242,7 @@ def create_desktop_entry( def create_manual_fallback(icon_path, exec_cmd): """Create desktop entry manually when portal is unavailable.""" - safe_name = "".join( - [c for c in program.get("name") if c.isalnum() or c in ("-", "_")] - ) - filename = f"bottles-{config.get('Name')}-{safe_name}.desktop" + filename = f"{config.get('Name')}-{program.get('name')}.desktop" content = ( f"[Desktop Entry]\n" f"Exec={exec_cmd}\n" @@ -361,6 +359,220 @@ def browse_wineprefix(wineprefix: dict): path_type="custom", custom_path=wineprefix.get("Path") ) + @staticmethod + def _get_desktop_entry_locations() -> list[str]: + """Get the locations where desktop entries may be stored.""" + locations = [os.path.expanduser("~/.local/share/applications")] + desktop_dir = GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_DESKTOP) + if desktop_dir: + locations.append(desktop_dir) + return locations + + @staticmethod + def update_desktop_entries_on_rename(old_bottle_name: str, new_bottle_name: str): + """ + Update desktop entries when a bottle is renamed. + + Searches for .desktop files by their Exec= line content (looking for + bottles-cli with -b 'old_bottle_name'), updates the reference, and + renames the file to match the new bottle name. + """ + # Pattern to match bottles-cli command with the old bottle name + bottle_pattern = re.compile( + r"bottles-cli\s+run\s+.*-b\s+['\"]" + re.escape(old_bottle_name) + r"['\"]" + ) + # Pattern to extract program name from Exec line + program_pattern = re.compile(r"-p\s+['\"]([^'\"]+)['\"]") + + for location in ManagerUtils._get_desktop_entry_locations(): + if not os.path.isdir(location): + continue + + for filename in os.listdir(location): + if not filename.endswith(".desktop"): + continue + + file_path = os.path.join(location, filename) + + # Skip broken symlinks or non-existent files + if not os.path.isfile(file_path): + continue + + try: + with open(file_path, "r") as f: + content = f.read() + + # Check if this file references the old bottle name + if not bottle_pattern.search(content): + continue + + # Extract program name for the new filename + program_match = program_pattern.search(content) + program_name = program_match.group(1) if program_match else None + + # Update the Exec line to reference the new bottle name + content = re.sub( + r"(-b\s+)(['\"])" + re.escape(old_bottle_name) + r"\2", + r"\g<1>\g<2>" + new_bottle_name + r"\2", + content, + ) + + # Determine new file path + if program_name: + new_filename = f"{new_bottle_name}-{program_name}.desktop" + new_path = os.path.join(location, new_filename) + else: + new_path = file_path + + with open(new_path, "w") as f: + f.write(content) + + # Preserve executable permission for desktop files + if location == GLib.get_user_special_dir( + GLib.UserDirectory.DIRECTORY_DESKTOP + ): + os.chmod(new_path, 0o755) + + # Remove old file if we renamed it + if new_path != file_path and os.path.exists(file_path): + os.remove(file_path) + + if new_path != file_path: + logging.info( + f"Renamed desktop entry: {filename} -> {new_filename}" + ) + else: + logging.info(f"Updated desktop entry: {filename}") + except Exception as e: + logging.warning(f"Failed to update desktop entry {filename}: {e}") + + @staticmethod + def update_desktop_entries_on_program_rename( + bottle_name: str, + old_program_name: str, + new_program_name: str, + bottle_path: Optional[str] = None, + ): + """ + Update desktop entries when a program is renamed. + + Searches for .desktop files by their Exec= line content (looking for + bottles-cli with -p 'old_program_name' and -b 'bottle_name') and updates + the references. If bottle_path is provided, also renames the icon file. + """ + # Pattern to match bottles-cli command with the old program name and bottle + program_pattern = re.compile( + r"bottles-cli\s+run\s+.*-p\s+['\"]" + + re.escape(old_program_name) + + r"['\"].*-b\s+['\"]" + + re.escape(bottle_name) + + r"['\"]" + ) + + # Rename icon file if bottle_path is provided + new_icon_path = None + if bottle_path: + icons_dir = os.path.join(bottle_path, "icons") + old_icon_path = os.path.join(icons_dir, f"{old_program_name}.png") + new_icon_path = os.path.join(icons_dir, f"{new_program_name}.png") + if os.path.exists(old_icon_path): + try: + shutil.move(old_icon_path, new_icon_path) + logging.info( + f"Renamed icon: {old_program_name}.png -> {new_program_name}.png" + ) + except Exception as e: + logging.warning(f"Failed to rename icon file: {e}") + new_icon_path = None + + for location in ManagerUtils._get_desktop_entry_locations(): + if not os.path.isdir(location): + continue + + for filename in os.listdir(location): + if not filename.endswith(".desktop"): + continue + + file_path = os.path.join(location, filename) + + # Skip broken symlinks or non-existent files + if not os.path.isfile(file_path): + continue + + try: + with open(file_path, "r") as f: + content = f.read() + + # Check if this file references the old program name in this bottle + if not program_pattern.search(content): + continue + + # Update the Exec line to reference the new program name + content = re.sub( + r"(-p\s+)(['\"])" + re.escape(old_program_name) + r"\2", + r"\g<1>\g<2>" + new_program_name + r"\2", + content, + ) + + # Update Comment, Name, and StartupWMClass fields + content = re.sub( + r"(Comment=Launch\s+)" + + re.escape(old_program_name) + + r"(\s+using Bottles\.)", + r"\g<1>" + new_program_name + r"\2", + content, + ) + content = re.sub( + r"(Name=)" + re.escape(old_program_name) + r"$", + r"\g<1>" + new_program_name, + content, + flags=re.MULTILINE, + ) + content = re.sub( + r"(StartupWMClass=)" + re.escape(old_program_name) + r"$", + r"\g<1>" + new_program_name, + content, + flags=re.MULTILINE, + ) + + # Update Icon path if we successfully renamed the icon file + if new_icon_path: + old_icon_pattern = os.path.join( + bottle_path, "icons", f"{old_program_name}.png" + ) + content = re.sub( + r"(Icon=)" + re.escape(old_icon_pattern) + r"$", + r"\g<1>" + new_icon_path, + content, + flags=re.MULTILINE, + ) + + # Rename the file to match the new program name + new_filename = f"{bottle_name}-{new_program_name}.desktop" + new_path = os.path.join(location, new_filename) + + with open(new_path, "w") as f: + f.write(content) + + # Preserve executable permission for desktop files + if location == GLib.get_user_special_dir( + GLib.UserDirectory.DIRECTORY_DESKTOP + ): + os.chmod(new_path, 0o755) + + # Remove old file if we renamed it + if new_path != file_path and os.path.exists(file_path): + os.remove(file_path) + + if new_path != file_path: + logging.info( + f"Renamed desktop entry: {filename} -> {new_filename}" + ) + else: + logging.info(f"Updated desktop entry: {filename}") + except Exception as e: + logging.warning(f"Failed to update desktop entry {filename}: {e}") + @staticmethod def get_languages( from_name=None, diff --git a/bottles/frontend/views/bottle_preferences.py b/bottles/frontend/views/bottle_preferences.py index d2f474b8d0..2dfab3cd9f 100644 --- a/bottles/frontend/views/bottle_preferences.py +++ b/bottles/frontend/views/bottle_preferences.py @@ -394,6 +394,9 @@ def __save_name(self, *_args): new_name = self.entry_name.get_text() old_name = self.config.Name + if new_name == old_name: + return + library_manager = LibraryManager() entries = library_manager.get_library() @@ -409,6 +412,9 @@ def __save_name(self, *_args): self.manager.update_config(config=self.config, key="Name", value=new_name) + # Update any .desktop files that reference the old bottle name + ManagerUtils.update_desktop_entries_on_rename(old_name, new_name) + self.manager.update_bottles(silent=True) # Updates backend bottles list and UI self.window.page_library.update() self.details.view_bottle.label_name.set_text(self.config.Name) diff --git a/bottles/frontend/widgets/program.py b/bottles/frontend/widgets/program.py index 5778029d51..ae460a083b 100644 --- a/bottles/frontend/widgets/program.py +++ b/bottles/frontend/widgets/program.py @@ -15,7 +15,6 @@ # along with this program. If not, see . # -import webbrowser from gettext import gettext as _ from gi.repository import Adw, Gtk @@ -37,6 +36,7 @@ from typing import Optional + # noinspection PyUnusedLocal @Gtk.Template(resource_path="/com/usebottles/bottles/program-entry.ui") class ProgramEntry(Adw.ActionRow): @@ -167,7 +167,6 @@ def __update_subtitle(self): import logging logging.debug(f"Failed to update playtime subtitle: {e}") - pass def show_launch_options_view(self, _widget=False): def update(_widget, config): @@ -317,6 +316,12 @@ def func(new_name): scope="External_Programs", ) + # Update any .desktop files that reference the old program name + bottle_path = ManagerUtils.get_bottle_path(self.config) + ManagerUtils.update_desktop_entries_on_program_rename( + self.config.Name, old_name, new_name, bottle_path + ) + def async_work(): library_manager = LibraryManager() entries = library_manager.get_library() @@ -356,13 +361,14 @@ def add_entry(self, _widget): "name": self.program["name"], "executable": self.program["executable"], "path": self.program["path"], - } + }, ) def _on_desktop_entry_created(data: Optional[Result] = None) -> None: self.window.show_toast( _('Desktop Entry created for "{0}"').format(self.program["name"]) ) + SignalManager.connect(Signals.DesktopEntryCreated, _on_desktop_entry_created) def add_to_library(self, _widget):