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
35 changes: 35 additions & 0 deletions Addon.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,13 +169,19 @@ def __init__(
self.url = url.strip()
self.relative_cache_path = ""
self.branch = branch.strip()
self.branch_display_name = branch.strip()
self.repo_type = Addon.Kind.WORKBENCH
self.description = None
self.tags = set() # Just a cache, loaded from Metadata
self.remote_last_updated: Optional[datetime.datetime] = None
self.stats = AddonStats()
self.score = 0

# In cases where there are multiple versions/branches/installations available for an addon,
# this dictionary is the mapping from the displayed name in the UI (as given in the
# catalog) to the Addon object.
self.sub_addons = {}

# To prevent multiple threads from running git actions on this repo at the
# same time
self.git_lock = Lock()
Expand Down Expand Up @@ -208,6 +214,14 @@ def __init__(
self._cached_license: str = ""
self._cached_update_date = None

def __eq__(self, other):
if not isinstance(other, Addon):
return NotImplemented
return self.name == other.name and self.branch_display_name == other.branch_display_name

def __hash__(self):
return hash((self.name, self.branch_display_name))

def _clean_url(self):
# The url should never end in ".git", so strip it if it's there
parsed_url = urlparse(self.url)
Expand Down Expand Up @@ -759,3 +773,24 @@ def import_from_addon(self, repo: Addon, all_repos: List[Addon]):
self.python_optional = [
option for option in self.python_optional if option not in self.python_requires
]


def cycle_to_sub_addon(original: Addon, sub_addon: Addon, addon_model):
"""Given an addon with sub-addons, cycle the sub-addon to be the primary. After this call,
the addon_list will contain the sub_addon, which will itself have a list of sub-addons that
now includes the original addon (and *not* itself).
:param original: The addon that is currently the primary
:param sub_addon: The sub-addon that should be the primary
:param addon_model: The PackageListItemModel containing all addons"""

addon_model.remove_item(original)

new_sub_dict = {original.branch_display_name: original}
for key, value in original.sub_addons.items():
if key != sub_addon.branch_display_name:
new_sub_dict[key] = value

sub_addon.sub_addons = new_sub_dict
original.sub_addons = {}

addon_model.append_item(sub_addon)
14 changes: 10 additions & 4 deletions AddonCatalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,10 @@ def instantiate_addon(self, addon_id: str) -> Addon:
else:
addon.set_status(Addon.Status.NO_UPDATE_AVAILABLE)

addon.branch_display_name = (
self.branch_display_name if self.branch_display_name else self.git_ref
)

return addon

@staticmethod
Expand Down Expand Up @@ -335,16 +339,18 @@ def add_metadata_to_entry(
raise RuntimeError(f"Addon {addon_id} index out of range")
self._dictionary[addon_id][index].metadata = metadata

def get_available_branches(self, addon_id: str) -> List[Tuple[str, str]]:
def get_available_branches(self, addon_id: str) -> List[str]:
"""For a given ID, get the list of available branches compatible with this version of
FreeCAD along with the branch display name. Either field may be empty, but not both. The
first entry in the list is expected to be the "primary"."""
FreeCAD.
:return: A list of branch display names (or git refs, if no display name is available)"""
if addon_id not in self._dictionary:
return []
result = []
for entry in self._dictionary[addon_id]:
if entry.is_compatible():
result.append((entry.git_ref, entry.branch_display_name))
result.append(
entry.branch_display_name if entry.branch_display_name else entry.git_ref
)
return result

def get_catalog(self) -> Dict[str, List[AddonCatalogEntry]]:
Expand Down
108 changes: 105 additions & 3 deletions AddonManager.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@

import os
import functools
import shutil
import tempfile
import threading
from typing import Dict
from typing import Dict, List

from PySideWrapper import QtGui, QtCore, QtWidgets, QtSvg

Expand All @@ -45,7 +46,7 @@
from Widgets.addonmanager_widget_global_buttons import WidgetGlobalButtonBar
from Widgets.addonmanager_widget_progress_bar import Progress
from package_list import PackageListItemModel
from Addon import Addon
from Addon import Addon, cycle_to_sub_addon
from addonmanager_python_deps_gui import (
PythonPackageManagerGui,
)
Expand Down Expand Up @@ -298,7 +299,7 @@ def launch(self) -> None:
self.composite_view.package_list.stop_loading.connect(self.stop_update)
self.composite_view.package_list.setEnabled(False)
self.composite_view.execute.connect(self.execute_macro)
self.composite_view.install.connect(self.launch_installer_gui)
self.composite_view.install.connect(self.update)
self.composite_view.uninstall.connect(self.remove)
self.composite_view.update.connect(self.update)
self.composite_view.update_status.connect(self.status_updated)
Expand Down Expand Up @@ -440,6 +441,7 @@ def populate_packages_table(self) -> None:
self.create_addon_list_worker = CreateAddonListWorker()
self.create_addon_list_worker.addon_repo.connect(self.add_addon_repo)
self.update_progress_bar(translate("AddonsInstaller", "Creating addon list"), 10, 100)
self.create_addon_list_worker.old_backups_found.connect(self.found_old_backups)
self.create_addon_list_worker.finished.connect(self.do_next_startup_phase) # Link to step 2
self.create_addon_list_worker.start()

Expand Down Expand Up @@ -600,6 +602,12 @@ def append_to_repos_list(self, repo: Addon) -> None:
self.item_model.append_item(repo)

def update(self, repo: Addon) -> None:
try:
self.prep_for_install(repo)
except OSError as e:
fci.Console.PrintError(e)
fci.Console.PrintError("\n\nInstallation cancelled: out of disk space?\n")
return
self.launch_installer_gui(repo)

def mark_repo_update_available(self, repo: Addon, available: bool) -> None:
Expand All @@ -610,6 +618,19 @@ def mark_repo_update_available(self, repo: Addon, available: bool) -> None:
self.item_model.reload_item(repo)
self.composite_view.package_details_controller.show_repo(repo)

def prep_for_install(self, installing_addon: Addon):
"""To prepare for installing an addon, we need to see if this is the current active branch:
if it is not, then we need to cycle the addon's attached to this addon ID, making this one
active. We'll also remove any existing addon installed with this ID, making a backup
so that we can recover from a failed update/branch switch."""

for catalog_addon in self.item_model.repos:
if catalog_addon.name == installing_addon.name:
if catalog_addon != installing_addon:
cycle_to_sub_addon(catalog_addon, installing_addon, self.item_model)
break
make_backup(installing_addon)

def launch_installer_gui(self, addon: Addon) -> None:
if self.installer_gui is not None:
fci.Console.PrintError(
Expand All @@ -624,6 +645,7 @@ def launch_installer_gui(self, addon: Addon) -> None:
else:
self.installer_gui = AddonInstallerGUI(addon, self.item_model.repos)
self.installer_gui.success.connect(self.on_package_status_changed)
self.installer_gui.success.connect(cleanup_pre_installation_backup)
self.installer_gui.finished.connect(self.cleanup_installer)
self.installer_gui.run() # Does not block

Expand Down Expand Up @@ -743,5 +765,85 @@ def open_addons_folder():
QtGui.QDesktopServices.openUrl(QtCore.QUrl.fromLocalFile(addons_folder))
return

@staticmethod
def found_old_backups(backups: List[str]):
handling = fci.Preferences().get("old_backup_handling")
if handling.lower().strip() == "never":
return
if handling.lower().strip() == "always":
delete_old_backups(backups)
return

backup_string = (
translate(
"AddonsInstaller",
f"The following auto-generated backups were found in your Mod directory:",
)
+ "\n"
)
for backup in backups:
backup_string += "• " + str(backup) + "\n"
backup_string += translate("AddonsInstaller", "Do you want to delete them now?")
dlg = QtWidgets.QMessageBox()
dlg.setObjectName("AddonManager_FoundOldBackups")
dlg.setText(backup_string)
dlg.setStandardButtons(
QtWidgets.QMessageBox.YesToAll
| QtWidgets.QMessageBox.Yes
| QtWidgets.QMessageBox.No
| QtWidgets.QMessageBox.NoToAll
)
dlg.setDefaultButton(QtWidgets.QMessageBox.No)
dlg.button(QtWidgets.QMessageBox.YesToAll).setText(
translate("AddonsInstaller", "Always", "'Always' delete old backups")
)
dlg.button(QtWidgets.QMessageBox.NoToAll).setText(
translate("AddonsInstaller", "Never", "'Never' delete old backups")
)
result = dlg.exec_()

if result == QtWidgets.QMessageBox.NoToAll:
fci.Preferences().set("old_backup_handling", "never")
return
if result == QtWidgets.QMessageBox.No:
return
if result == QtWidgets.QMessageBox.YesToAll:
fci.Preferences().set("old_backup_handling", "always")
delete_old_backups(backups)


# Some utility functions


def make_backup(addon: Addon) -> None:
"""Make a backup of the addon's current installation directory, so that we can recover
from a failed update/branch switch."""
original = str(os.path.join(fci.DataPaths().mod_dir, addon.name))
if os.path.exists(original):
shutil.copytree(original, original + ".pre_update_backup", dirs_exist_ok=True)


Copy link

Copilot AI Aug 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The backup creation could fail due to permissions or disk space issues, but there's no error handling or user feedback. Consider adding try-catch and appropriate error messaging.

Suggested change
try:
shutil.copytree(original, original + ".pre_update_backup", dirs_exist_ok=True)
except Exception as e:
fci.Console.PrintError(
translate(
"AddonsInstaller",
f"Failed to create backup for addon '{addon.name}': {e}"
)
)

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Aug 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dirs_exist_ok=True parameter allows overwriting existing backup directories without warning. This could silently overwrite a previous backup, potentially losing important recovery data.

Suggested change
backup_dir = original + ".pre_update_backup"
if os.path.exists(original):
if os.path.exists(backup_dir):
# Warn the user and skip backup to avoid overwriting previous backup
fci.Console.PrintError(
f"Backup directory '{backup_dir}' already exists. Skipping backup to avoid overwriting previous backup."
)
return
shutil.copytree(original, backup_dir)

Copilot uses AI. Check for mistakes.
def cleanup_pre_installation_backup(addon: Addon) -> None:
"""Remove the backup of the addon's current installation directory"""
original = str(os.path.join(fci.DataPaths().mod_dir, addon.name))
if os.path.exists(original):
shutil.rmtree(original + ".pre_update_backup", ignore_errors=True)


def revert_to_backup(addon: Addon) -> None:
"""Revert to the backup of the addon's current installation directory"""
original = str(os.path.join(fci.DataPaths().mod_dir, addon.name))
if os.path.exists(original + ".pre_update_backup"):
shutil.rmtree(original, ignore_errors=True)
shutil.copytree(original + ".pre_update_backup", original, dirs_exist_ok=True)


def delete_old_backups(backups) -> None:
"""Delete old backups found in the Mod directory."""
for backup in backups:
full_path = os.path.join(fci.DataPaths().mod_dir, backup)
if os.path.exists(full_path):
shutil.rmtree(full_path, ignore_errors=True)


# @}
Loading