Skip to content
Open
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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
327 changes: 298 additions & 29 deletions bottles/backend/utils/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import os
import re
import shlex
import shutil
from gettext import gettext as _
Expand Down Expand Up @@ -239,18 +240,75 @@ 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."""
filename = f"{config.get('Name')}-{program.get('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={}
Expand All @@ -259,25 +317,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.")
Expand All @@ -291,11 +340,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):
Expand All @@ -304,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,
Expand Down
Loading
Loading