diff --git a/src/Mod/AddonManager/AddonManager.py b/src/Mod/AddonManager/AddonManager.py index ae8b6e887b..13c2440cf8 100644 --- a/src/Mod/AddonManager/AddonManager.py +++ b/src/Mod/AddonManager/AddonManager.py @@ -29,8 +29,7 @@ import shutil import stat import tempfile from datetime import date, timedelta -from typing import Dict, Union -from enum import Enum +from typing import Dict from PySide2 import QtGui, QtCore, QtWidgets import FreeCADGui @@ -82,6 +81,7 @@ class CommandAddonManager: "macro_worker", "install_worker", "update_metadata_cache_worker", + "load_macro_metadata_worker", "update_all_worker", "update_check_single_worker", ] @@ -109,32 +109,66 @@ class CommandAddonManager: def Activated(self) -> None: # display first use dialog if needed - readWarningParameter = FreeCAD.ParamGet( - "User parameter:BaseApp/Preferences/Addons" - ) - readWarning = readWarningParameter.GetBool("readWarning", False) - newReadWarningParameter = FreeCAD.ParamGet( - "User parameter:Plugins/addonsRepository" - ) - readWarning |= newReadWarningParameter.GetBool("readWarning", False) + pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") + readWarning = pref.GetBool("readWarning2022", False) + if not readWarning: - if ( - QtWidgets.QMessageBox.warning( - None, - "FreeCAD", - translate( - "AddonsInstaller", - "The addons that can be installed here are not " - "officially part of FreeCAD, and are not reviewed " - "by the FreeCAD team. Make sure you know what you " - "are installing!", - ), - QtWidgets.QMessageBox.Cancel | QtWidgets.QMessageBox.Ok, - ) - != QtWidgets.QMessageBox.StandardButton.Cancel - ): - readWarningParameter.SetBool("readWarning", True) + warning_dialog = FreeCADGui.PySideUic.loadUi( + os.path.join(os.path.dirname(__file__), "first_run.ui") + ) + autocheck = pref.GetBool("AutoCheck", False) + download_macros = pref.GetBool("DownloadMacros", False) + proxy_string = pref.GetString("ProxyUrl", "") + if pref.GetBool("NoProxyCheck", True): + proxy_option = 0 + elif pref.GetBool("SystemProxyCheck", False): + proxy_option = 1 + elif pref.GetBool("UserProxyCheck", False): + proxy_option = 2 + + def toggle_proxy_list(option: int): + if option == 2: + warning_dialog.lineEditProxy.show() + else: + warning_dialog.lineEditProxy.hide() + + warning_dialog.checkBoxAutoCheck.setChecked(autocheck) + warning_dialog.checkBoxDownloadMacroMetadata.setChecked(download_macros) + warning_dialog.comboBoxProxy.setCurrentIndex(proxy_option) + toggle_proxy_list(proxy_option) + if proxy_option == 2: + warning_dialog.lineEditProxy.setText(proxy_string) + + warning_dialog.comboBoxProxy.currentIndexChanged.connect(toggle_proxy_list) + + warning_dialog.labelWarning.setStyleSheet( + f"color:{utils.warning_color_string()};font-weight:bold;" + ) + + if warning_dialog.exec() == QtWidgets.QDialog.Accepted: readWarning = True + pref.SetBool("readWarning2022", True) + pref.SetBool("AutoCheck", warning_dialog.checkBoxAutoCheck.isChecked()) + pref.SetBool( + "DownloadMacros", + warning_dialog.checkBoxDownloadMacroMetadata.isChecked(), + ) + if warning_dialog.checkBoxDownloadMacroMetadata.isChecked(): + self.trigger_recache = True + selected_proxy_option = warning_dialog.comboBoxProxy.currentIndex() + if selected_proxy_option == 0: + pref.SetBool("NoProxyCheck", True) + pref.SetBool("SystemProxyCheck", False) + pref.SetBool("UserProxyCheck", False) + elif selected_proxy_option == 1: + pref.SetBool("NoProxyCheck", False) + pref.SetBool("SystemProxyCheck", True) + pref.SetBool("UserProxyCheck", False) + else: + pref.SetBool("NoProxyCheck", False) + pref.SetBool("SystemProxyCheck", False) + pref.SetBool("UserProxyCheck", True) + pref.SetString("ProxyUrl", warning_dialog.lineEditProxy.text()) if readWarning: self.launch() @@ -168,6 +202,8 @@ class CommandAddonManager: # 0: Update every launch # >0: Update every n days self.update_cache = False + if hasattr(self, "trigger_recache") and self.trigger_recache: + self.update_cache = True update_frequency = pref.GetInt("UpdateFrequencyComboEntry", 0) if update_frequency == 0: days_between_updates = -1 @@ -241,7 +277,6 @@ class CommandAddonManager: ) self.dialog.buttonClose.clicked.connect(self.dialog.reject) self.dialog.buttonUpdateCache.clicked.connect(self.on_buttonUpdateCache_clicked) - self.dialog.buttonShowDetails.clicked.connect(self.toggle_details) self.dialog.buttonPauseUpdate.clicked.connect(self.stop_update) self.packageList.itemSelected.connect(self.table_row_activated) self.packageList.setEnabled(False) @@ -262,7 +297,7 @@ class CommandAddonManager: ) # set info for the progress bar: - self.dialog.progressBar.setMaximum(100) + self.dialog.progressBar.setMaximum(1000) # begin populating the table in a set of sub-threads self.startup() @@ -385,6 +420,9 @@ class CommandAddonManager: self.update_metadata_cache, self.check_updates, ] + pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") + if pref.GetBool("DownloadMacros", False): + self.startup_sequence.append(self.load_macro_metadata) self.current_progress_region = 0 self.number_of_progress_regions = len(self.startup_sequence) self.do_next_startup_phase() @@ -410,7 +448,6 @@ class CommandAddonManager: def populate_packages_table(self) -> None: self.item_model.clear() - self.current_progress_region += 1 use_cache = not self.update_cache if use_cache: @@ -456,7 +493,7 @@ class CommandAddonManager: if hasattr(self, "package_cache"): package_cache_path = self.get_cache_file_name("package_cache.json") with open(package_cache_path, "w") as f: - f.write(json.dumps(self.package_cache)) + f.write(json.dumps(self.package_cache, indent=" ")) def activate_table_widgets(self) -> None: self.packageList.setEnabled(True) @@ -464,42 +501,44 @@ class CommandAddonManager: self.do_next_startup_phase() def populate_macros(self) -> None: - self.current_progress_region += 1 - if self.update_cache or not os.path.isfile( - self.get_cache_file_name("macro_cache.json") - ): + macro_cache_file = self.get_cache_file_name("macro_cache.json") + cache_is_bad = True + if os.path.isfile(macro_cache_file): + size = os.path.getsize(macro_cache_file) + if size > 1000: # Make sure there is actually data in there + cache_is_bad = False + if self.update_cache or cache_is_bad: self.macro_worker = FillMacroListWorker(self.get_cache_file_name("Macros")) self.macro_worker.status_message_signal.connect(self.show_information) self.macro_worker.progress_made.connect(self.update_progress_bar) self.macro_worker.add_macro_signal.connect(self.add_addon_repo) - self.macro_worker.finished.connect( - self.do_next_startup_phase - ) # Link to step 3 + self.macro_worker.finished.connect(self.do_next_startup_phase) self.macro_worker.start() else: self.macro_worker = LoadMacrosFromCacheWorker( self.get_cache_file_name("macro_cache.json") ) self.macro_worker.add_macro_signal.connect(self.add_addon_repo) - self.macro_worker.finished.connect( - self.do_next_startup_phase - ) # Link to step 3 + self.macro_worker.finished.connect(self.do_next_startup_phase) self.macro_worker.start() - def cache_macro(self, macro: AddonManagerRepo): + def cache_macro(self, repo: AddonManagerRepo): if not hasattr(self, "macro_cache"): self.macro_cache = [] - if macro.macro is not None: - self.macro_cache.append(macro.macro.to_cache()) + if repo.macro is not None: + self.macro_cache.append(repo.macro.to_cache()) + else: + FreeCAD.Console.PrintError( + f"Addon Manager: Internal error, cache_macro called on non-macro {repo.name}\n" + ) def write_macro_cache(self): macro_cache_path = self.get_cache_file_name("macro_cache.json") with open(macro_cache_path, "w") as f: - f.write(json.dumps(self.macro_cache)) + f.write(json.dumps(self.macro_cache, indent=" ")) self.macro_cache = [] def update_metadata_cache(self) -> None: - self.current_progress_region += 1 if self.update_cache: self.update_metadata_cache_worker = UpdateMetadataCacheWorker( self.item_model.repos @@ -528,14 +567,29 @@ class CommandAddonManager: """Called when the named package has either new metadata or a new icon (or both)""" with self.lock: - self.cache_package(repo) repo.icon = self.get_icon(repo, update=True) self.item_model.reload_item(repo) + def load_macro_metadata(self) -> None: + if self.update_cache: + self.load_macro_metadata_worker = CacheMacroCode(self.item_model.repos) + self.load_macro_metadata_worker.status_message.connect( + self.show_information + ) + self.load_macro_metadata_worker.update_macro.connect( + self.on_package_updated + ) + self.load_macro_metadata_worker.progress_made.connect( + self.update_progress_bar + ) + self.load_macro_metadata_worker.finished.connect(self.do_next_startup_phase) + self.load_macro_metadata_worker.start() + else: + self.do_next_startup_phase() + def check_updates(self) -> None: "checks every installed addon for available updates" - self.current_progress_region += 1 pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") autocheck = pref.GetBool("AutoCheck", False) if not autocheck: @@ -645,6 +699,7 @@ class CommandAddonManager: """shows generic text in the information pane (which might be collapsed)""" self.dialog.labelStatusInfo.setText(message) + self.dialog.labelStatusInfo.repaint() def show_workbench(self, repo: AddonManagerRepo) -> None: self.packageList.hide() @@ -700,6 +755,8 @@ class CommandAddonManager: real_install_succeeded, errors = macro.install(self.macro_repo_dir) if not real_install_succeeded: failed = True + else: + utils.update_macro_installation_details(repo) if not failed: message = translate( @@ -851,36 +908,31 @@ class CommandAddonManager: self.dialog.labelStatusInfo.hide() self.dialog.progressBar.hide() self.dialog.buttonPauseUpdate.hide() - self.dialog.buttonShowDetails.hide() - self.dialog.labelUpdateInProgress.hide() self.packageList.ui.lineEditFilter.setFocus() def show_progress_widgets(self) -> None: if self.dialog.progressBar.isHidden(): self.dialog.progressBar.show() self.dialog.buttonPauseUpdate.show() - self.dialog.buttonShowDetails.show() - self.dialog.labelStatusInfo.hide() - self.dialog.buttonShowDetails.setArrowType(QtCore.Qt.RightArrow) - self.dialog.labelUpdateInProgress.show() + self.dialog.labelStatusInfo.show() def update_progress_bar(self, current_value: int, max_value: int) -> None: """Update the progress bar, showing it if it's hidden""" - self.show_progress_widgets() - region_size = 100 / self.number_of_progress_regions - value = (self.current_progress_region - 1) * region_size + ( - current_value / max_value / self.number_of_progress_regions - ) * region_size - self.dialog.progressBar.setValue(value) + if current_value < 0: + FreeCAD.Console.PrintWarning( + f"Addon Manager: Internal error, current progress value is negative in region {self.current_progress_region}" + ) - def toggle_details(self) -> None: - if self.dialog.labelStatusInfo.isHidden(): - self.dialog.labelStatusInfo.show() - self.dialog.buttonShowDetails.setArrowType(QtCore.Qt.DownArrow) - else: - self.dialog.labelStatusInfo.hide() - self.dialog.buttonShowDetails.setArrowType(QtCore.Qt.RightArrow) + self.show_progress_widgets() + region_size = 100.0 / self.number_of_progress_regions + completed_region_portion = (self.current_progress_region - 1) * region_size + current_region_portion = (float(current_value) / float(max_value)) * region_size + value = completed_region_portion + current_region_portion + self.dialog.progressBar.setValue( + value * 10 + ) # Out of 1000 segments, so it moves sort of smoothly + self.dialog.progressBar.repaint() def stop_update(self) -> None: self.cleanup_workers() diff --git a/src/Mod/AddonManager/AddonManager.ui b/src/Mod/AddonManager/AddonManager.ui index ac9269af0a..0b1be55d46 100644 --- a/src/Mod/AddonManager/AddonManager.ui +++ b/src/Mod/AddonManager/AddonManager.ui @@ -28,26 +28,6 @@ - - - - Show details - - - ... - - - Qt::RightArrow - - - - - - - Loading... - - - diff --git a/src/Mod/AddonManager/AddonManagerOptions.ui b/src/Mod/AddonManager/AddonManagerOptions.ui index e022702023..33970bb76c 100644 --- a/src/Mod/AddonManager/AddonManagerOptions.ui +++ b/src/Mod/AddonManager/AddonManagerOptions.ui @@ -6,8 +6,8 @@ 0 0 - 390 - 628 + 388 + 621 @@ -35,6 +35,19 @@ installed addons will be checked for available updates + + + + Download Macro metadata (approximately 10MB) + + + DownloadMacros + + + Addons + + + diff --git a/src/Mod/AddonManager/CMakeLists.txt b/src/Mod/AddonManager/CMakeLists.txt index de711853f1..9ef2819ea1 100644 --- a/src/Mod/AddonManager/CMakeLists.txt +++ b/src/Mod/AddonManager/CMakeLists.txt @@ -13,6 +13,7 @@ SET(AddonManager_SRCS addonmanager_workers.py AddonManager.ui AddonManagerOptions.ui + first_run.ui compact_view.py expanded_view.py package_list.py diff --git a/src/Mod/AddonManager/addonmanager_macro.py b/src/Mod/AddonManager/addonmanager_macro.py index d602dabd15..65a8739126 100644 --- a/src/Mod/AddonManager/addonmanager_macro.py +++ b/src/Mod/AddonManager/addonmanager_macro.py @@ -23,10 +23,11 @@ import os import re -import sys +import io import codecs import shutil -from typing import Dict, Union, List +import time +from typing import Dict, Tuple, List, Union import FreeCAD @@ -56,10 +57,13 @@ class Macro(object): self.on_wiki = False self.on_git = False self.desc = "" + self.comment = "" self.code = "" self.url = "" self.version = "" + self.date = "" self.src_filename = "" + self.author = "" self.other_files = [] self.parsed = False @@ -93,37 +97,71 @@ class Macro(object): os.path.join(FreeCAD.getUserMacroDir(True), "Macro_" + self.filename) ) - def fill_details_from_file(self, filename): - with open(filename) as f: - # Number of parsed fields of metadata. For now, __Comment__, - # __Web__, __Version__, __Files__. - number_of_required_fields = 4 - re_desc = re.compile(r"^__Comment__\s*=\s*(['\"])(.*)\1") - re_url = re.compile(r"^__Web__\s*=\s*(['\"])(.*)\1") - re_version = re.compile(r"^__Version__\s*=\s*(['\"])(.*)\1") - re_files = re.compile(r"^__Files__\s*=\s*(['\"])(.*)\1") - for line in f.readlines(): - match = re.match(re_desc, line) - if match: - self.desc = match.group(2) - number_of_required_fields -= 1 - match = re.match(re_url, line) - if match: - self.url = match.group(2) - number_of_required_fields -= 1 - match = re.match(re_version, line) - if match: - self.version = match.group(2) - number_of_required_fields -= 1 - match = re.match(re_files, line) - if match: - self.other_files = [of.strip() for of in match.group(2).split(",")] - number_of_required_fields -= 1 - if number_of_required_fields <= 0: - break - f.seek(0) + def fill_details_from_file(self, filename: str) -> None: + with open(filename, errors="replace") as f: self.code = f.read() - self.parsed = True + self.fill_details_from_code(self.code) + + def fill_details_from_code(self, code: str) -> None: + # Number of parsed fields of metadata. Overrides anything set previously (the code is considered authoritative). + # For now: + # __Comment__ + # __Web__ + # __Version__ + # __Files__ + # __Author__ + # __Date__ + max_lines_to_search = 50 + line_counter = 0 + number_of_fields = 5 + ic = re.IGNORECASE # Shorten the line for Black + re_comment = re.compile(r"^__Comment__\s*=\s*(['\"])(.*)\1", flags=ic) + re_url = re.compile(r"^__Web__\s*=\s*(['\"])(.*)\1", flags=ic) + re_version = re.compile(r"^__Version__\s*=\s*(['\"])(.*)\1", flags=ic) + re_files = re.compile(r"^__Files__\s*=\s*(['\"])(.*)\1", flags=ic) + re_author = re.compile(r"^__Author__\s*=\s*(['\"])(.*)\1", flags=ic) + re_date = re.compile(r"^__Date__\s*=\s*(['\"])(.*)\1", flags=ic) + + f = io.StringIO(code) + while f and line_counter < max_lines_to_search: + line = f.readline() + line_counter += 1 + if not line.startswith( + "__" + ): # Speed things up a bit... this comparison is very cheap + continue + match = re.match(re_comment, line) + if match: + self.comment = match.group(2) + self.comment = re.sub("<.*?>", "", self.comment) # Strip any HTML tags + number_of_fields -= 1 + match = re.match(re_author, line) + if match: + self.author = match.group(2) + number_of_fields -= 1 + match = re.match(re_url, line) + if match: + self.url = match.group(2) + number_of_fields -= 1 + match = re.match(re_version, line) + if match: + self.version = match.group(2) + number_of_fields -= 1 + match = re.match(re_date, line) + if match: + self.date = match.group(2) + number_of_fields -= 1 + match = re.match(re_files, line) + if match: + self.other_files = [of.strip() for of in match.group(2).split(",")] + number_of_fields -= 1 + if number_of_fields <= 0: + break + + # Truncate long comments to speed up searches, and clean up display + if len(self.comment) > 512: + self.comment = self.comment[:511] + "…" + self.parsed = True def fill_details_from_wiki(self, url): code = "" @@ -157,13 +195,9 @@ class Macro(object): + "\n" ) return - # code = u2.read() - # github is slow to respond... We need to use this trick below response = "" block = 8192 - # expected = int(u2.headers["content-length"]) while True: - # print("expected:", expected, "got:", len(response)) data = u2.read(block) if not data: break @@ -200,12 +234,14 @@ class Macro(object): FreeCAD.Console.PrintWarning( translate( "AddonsInstaller", - "Unable to retrieve a description for this macro.", + f"Unable to retrieve a description from the wiki for macro {self.name}", ) + "\n" ) desc = "No description available" self.desc = desc + self.comment, _, _ = desc.partition("", "", self.comment) # Strip any tags self.url = url if isinstance(code, list): flat_code = "" @@ -213,9 +249,20 @@ class Macro(object): flat_code += chunk code = flat_code self.code = code - self.parsed = True + self.fill_details_from_code(self.code) + if not self.author: + self.author = self.parse_desc("Author: ") + if not self.date: + self.date = self.parse_desc("Last modified: ") - def install(self, macro_dir: str) -> (bool, List[str]): + def parse_desc(self, line_start: str) -> Union[str, None]: + components = self.desc.split(">") + for component in components: + if component.startswith(line_start): + end = component.find("<") + return component[len(line_start) : end] + + def install(self, macro_dir: str) -> Tuple[bool, List[str]]: """Install a macro and all its related files Returns True if the macro was installed correctly. diff --git a/src/Mod/AddonManager/addonmanager_utilities.py b/src/Mod/AddonManager/addonmanager_utilities.py index 532c46ec67..1ab5fafc15 100644 --- a/src/Mod/AddonManager/addonmanager_utilities.py +++ b/src/Mod/AddonManager/addonmanager_utilities.py @@ -21,21 +21,19 @@ # * * # *************************************************************************** -import codecs import os import re -import shutil -import sys import ctypes -import tempfile import ssl +from typing import Union import urllib from urllib.request import Request from urllib.error import URLError from urllib.parse import urlparse +from http.client import HTTPResponse -from PySide2 import QtGui, QtCore, QtWidgets +from PySide2 import QtCore, QtWidgets import FreeCAD import FreeCADGui @@ -94,7 +92,7 @@ def symlink(source, link_name): raise ctypes.WinError() -def urlopen(url: str): +def urlopen(url: str) -> Union[None, HTTPResponse]: """Opens an url with urllib and streams it to a temp file""" timeout = 5 @@ -125,9 +123,7 @@ def urlopen(url: str): u = urllib.request.urlopen(req, timeout=timeout) except URLError as e: - FreeCAD.Console.PrintError( - translate("AddonsInstaller", f"Error loading {url}") + ":\n {e.reason}\n" - ) + FreeCAD.Console.PrintLog(f"Error loading {url}:\n {e.reason}\n") return None except Exception: return None @@ -298,4 +294,76 @@ def fix_relative_links(text, base_url): return new_text +def warning_color_string() -> str: + """A shade of red, adapted to darkmode if possible. Targets a minimum 7:1 contrast ratio.""" + + warningColorString = "rgb(255,0,0)" + if hasattr(QtWidgets.QApplication.instance(), "styleSheet"): + # Qt 5.9 doesn't give a QApplication instance, so can't give the stylesheet info + if "dark" in QtWidgets.QApplication.instance().styleSheet().lower(): + warningColorString = "rgb(255,105,97)" + else: + warningColorString = "rgb(215,0,21)" + return warningColorString + + +def bright_color_string() -> str: + """A shade of green, adapted to darkmode if possible. Targets a minimum 7:1 contrast ratio.""" + + brightColorString = "rgb(0,255,0)" + if hasattr(QtWidgets.QApplication.instance(), "styleSheet"): + # Qt 5.9 doesn't give a QApplication instance, so can't give the stylesheet info + if "dark" in QtWidgets.QApplication.instance().styleSheet().lower(): + brightColorString = "rgb(48,219,91)" + else: + brightColorString = "rgb(36,138,61)" + return brightColorString + + +def attention_color_string() -> str: + """A shade of orange, adapted to darkmode if possible. Targets a minimum 7:1 contrast ratio.""" + + attentionColorString = "rgb(255,149,0)" + if hasattr(QtWidgets.QApplication.instance(), "styleSheet"): + # Qt 5.9 doesn't give a QApplication instance, so can't give the stylesheet info + if "dark" in QtWidgets.QApplication.instance().styleSheet().lower(): + attentionColorString = "rgb(255,179,64)" + else: + attentionColorString = "rgb(255,149,0)" + return attentionColorString + + +def get_macro_version_from_file(filename: str) -> str: + re_version = re.compile(r"^__Version__\s*=\s*(['\"])(.*)\1", flags=re.IGNORECASE) + with open(filename, "r", errors="ignore") as f: + line_counter = 0 + max_lines_to_scan = 50 + while line_counter < max_lines_to_scan: + line_counter += 1 + line = f.readline() + if line.startswith("__"): + match = re.match(re_version, line) + if match: + return match.group(2) + return "" + + +def update_macro_installation_details(repo) -> None: + if repo is None or not hasattr(repo, "macro") or repo.macro is None: + FreeCAD.Console.PrintLog(f"Requested macro details for non-macro object\n") + return + test_file_one = os.path.join(FreeCAD.getUserMacroDir(True), repo.macro.filename) + test_file_two = os.path.join( + FreeCAD.getUserMacroDir(True), "Macro_" + repo.macro.filename + ) + if os.path.exists(test_file_one): + repo.updated_timestamp = os.path.getmtime(test_file_one) + repo.installed_version = get_macro_version_from_file(test_file_one) + elif os.path.exists(test_file_two): + repo.updated_timestamp = os.path.getmtime(test_file_two) + repo.installed_version = get_macro_version_from_file(test_file_two) + else: + return + + # @} diff --git a/src/Mod/AddonManager/addonmanager_workers.py b/src/Mod/AddonManager/addonmanager_workers.py index 1460301b11..45dd516e12 100644 --- a/src/Mod/AddonManager/addonmanager_workers.py +++ b/src/Mod/AddonManager/addonmanager_workers.py @@ -31,10 +31,11 @@ import hashlib import threading import queue import io +import time from datetime import datetime from typing import Union, List -from PySide2 import QtCore, QtGui, QtNetwork +from PySide2 import QtCore, QtNetwork import FreeCAD @@ -285,7 +286,6 @@ class LoadPackagesFromCacheWorker(QtCore.QThread): class LoadMacrosFromCacheWorker(QtCore.QThread): add_macro_signal = QtCore.Signal(object) - done = QtCore.Signal() def __init__(self, cache_file: str): QtCore.QThread.__init__(self) @@ -299,8 +299,9 @@ class LoadMacrosFromCacheWorker(QtCore.QThread): if QtCore.QThread.currentThread().isInterruptionRequested(): return new_macro = Macro.from_cache(item) - self.add_macro_signal.emit(AddonManagerRepo.from_macro(new_macro)) - self.done.emit() + repo = AddonManagerRepo.from_macro(new_macro) + utils.update_macro_installation_details(repo) + self.add_macro_signal.emit(repo) class CheckWorkbenchesForUpdatesWorker(QtCore.QThread): @@ -589,6 +590,7 @@ class FillMacroListWorker(QtCore.QThread): macro.src_filename = os.path.join(dirpath, filename) repo = AddonManagerRepo.from_macro(macro) repo.url = "https://github.com/FreeCAD/FreeCAD-macros.git" + utils.update_macro_installation_details(repo) self.add_macro_signal.emit(repo) def retrieve_macros_from_wiki(self): @@ -634,9 +636,137 @@ class FillMacroListWorker(QtCore.QThread): macro.on_wiki = True repo = AddonManagerRepo.from_macro(macro) repo.url = "https://wiki.freecad.org/Macros_recipes" + utils.update_macro_installation_details(repo) self.add_macro_signal.emit(repo) +class CacheMacroCode(QtCore.QThread): + """Download and cache the macro code, and parse its internal metadata""" + + status_message = QtCore.Signal(str) + update_macro = QtCore.Signal(AddonManagerRepo) + progress_made = QtCore.Signal(int, int) + + def __init__(self, repos: List[AddonManagerRepo]) -> None: + QtCore.QThread.__init__(self) + self.repos = repos + self.workers = [] + self.terminators = [] + self.lock = threading.Lock() + self.failed = [] + self.counter = 0 + + def run(self): + self.status_message.emit(translate("AddonsInstaller", "Caching macro code...")) + + self.repo_queue = queue.Queue() + current_thread = QtCore.QThread.currentThread() + num_macros = 0 + for repo in self.repos: + if repo.macro is not None: + self.repo_queue.put(repo) + num_macros += 1 + + # Emulate QNetworkAccessManager and spool up six connections: + for _ in range(6): + self.update_and_advance(None) + + while True: + if current_thread.isInterruptionRequested(): + for worker in self.workers: + worker.requestInterruption() + worker.wait(100) + if not worker.isFinished(): + # Kill it + worker.terminate() + return + # Ensure our signals propagate out by running an internal thread-local event loop + QtCore.QCoreApplication.processEvents() + with self.lock: + if self.counter >= num_macros: + break + time.sleep(0.1) + + # Make sure all of our child threads have fully exited: + for i, worker in enumerate(self.workers): + worker.wait(50) + if not worker.isFinished(): + FreeCAD.Console.PrintError( + f"Addon Manager: a worker process failed to complete while fetching {worker.macro.name}\n" + ) + worker.terminate() + + self.repo_queue.join() + for terminator in self.terminators: + if terminator and terminator.isActive(): + terminator.stop() + + if len(self.failed) > 0: + num_failed = len(self.failed) + FreeCAD.Console.PrintWarning( + translate( + "AddonsInstaller", + f"Out of {num_macros} macros, {num_failed} timed out while processing", + ) + ) + + def update_and_advance(self, repo: AddonManagerRepo) -> None: + if repo is not None: + if repo.macro.name not in self.failed: + self.update_macro.emit(repo) + self.repo_queue.task_done() + with self.lock: + self.counter += 1 + + if QtCore.QThread.currentThread().isInterruptionRequested(): + return + + self.progress_made.emit( + len(self.repos) - self.repo_queue.qsize(), len(self.repos) + ) + + try: + next_repo = self.repo_queue.get_nowait() + worker = GetMacroDetailsWorker(next_repo) + worker.finished.connect(lambda: self.update_and_advance(next_repo)) + with self.lock: + self.workers.append(worker) + self.terminators.append( + QtCore.QTimer.singleShot(10000, lambda: self.terminate(worker)) + ) + self.status_message.emit( + translate( + "AddonsInstaller", + f"Getting metadata from macro {next_repo.macro.name}", + ) + ) + worker.start() + except queue.Empty: + pass + + def terminate(self, worker) -> None: + if not worker.isFinished(): + macro_name = worker.macro.name + FreeCAD.Console.PrintWarning( + translate( + "AddonsInstaller", + f"Timeout while fetching metadata for macro {macro_name}", + ) + + "\n" + ) + worker.requestInterruption() + worker.wait(100) + if worker.isRunning(): + worker.terminate() + worker.wait(50) + if worker.isRunning(): + FreeCAD.Console.PrintError( + f"Failed to kill process for macro {macro_name}!\n" + ) + with self.lock: + self.failed.append(macro_name) + + class ShowWorker(QtCore.QThread): """This worker retrieves info of a given workbench""" @@ -927,17 +1057,8 @@ class GetMacroDetailsWorker(QtCore.QThread): mac = mac.replace("+", "%2B") url = "https://wiki.freecad.org/Macro_" + mac self.macro.fill_details_from_wiki(url) - if self.macro.is_installed(): - already_installed_msg = ( - '' - + translate("AddonsInstaller", "This macro is already installed.") - + "
" - ) - else: - already_installed_msg = "" message = ( - already_installed_msg - + "

" + "

" + self.macro.name + "

" + self.macro.desc @@ -1545,11 +1666,15 @@ class UpdateAllWorker(QtCore.QThread): self.done.emit() def on_success(self, repo: AddonManagerRepo) -> None: - self.progress_made.emit(self.repo_queue.qsize(), len(self.repos)) + self.progress_made.emit( + len(self.repos) - self.repo_queue.qsize(), len(self.repos) + ) self.success.emit(repo) def on_failure(self, repo: AddonManagerRepo) -> None: - self.progress_made.emit(self.repo_queue.qsize(), len(self.repos)) + self.progress_made.emit( + len(self.repos) - self.repo_queue.qsize(), len(self.repos) + ) self.failure.emit(repo) @@ -1589,6 +1714,7 @@ class UpdateSingleWorker(QtCore.QThread): install_succeeded, errors = repo.macro.install( FreeCAD.getUserMacroDir(True) ) + utils.update_macro_installation_details(repo) if install_succeeded: self.success.emit(repo) diff --git a/src/Mod/AddonManager/first_run.ui b/src/Mod/AddonManager/first_run.ui new file mode 100644 index 0000000000..4daf95476c --- /dev/null +++ b/src/Mod/AddonManager/first_run.ui @@ -0,0 +1,144 @@ + + + Dialog + + + Qt::WindowModal + + + + 0 + 0 + 398 + 237 + + + + Welcome to the Addon Manager + + + + + + The addons that can be installed here are not officially part of FreeCAD, and are not reviewed by the FreeCAD team. Make sure you know what you are installing! + + + true + + + + + + + Qt::Horizontal + + + + + + + + 75 + true + + + + Download Settings + + + + + + + Automatically check installed Addons for updates + + + + + + + Download Macro metadata (approximately 10MB) + + + + + + + + + + No proxy + + + + + System proxy + + + + + User-defined proxy: + + + + + + + + + + + + + These and other settings are available in the FreeCAD Preferences window. + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + buttonBox + accepted() + Dialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + Dialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/Mod/AddonManager/package_details.py b/src/Mod/AddonManager/package_details.py index 2e06781a3a..508073d7d4 100644 --- a/src/Mod/AddonManager/package_details.py +++ b/src/Mod/AddonManager/package_details.py @@ -31,12 +31,14 @@ from datetime import date, timedelta import FreeCAD -from addonmanager_utilities import translate # this needs to be as is for pylupdate +import addonmanager_utilities as utils from addonmanager_workers import ShowWorker, GetMacroDetailsWorker from AddonManagerRepo import AddonManagerRepo import inspect +translate = FreeCAD.Qt.translate + class PackageDetails(QWidget): @@ -93,31 +95,34 @@ class PackageDetails(QWidget): self.ui.buttonExecute.hide() if repo.update_status != AddonManagerRepo.UpdateStatus.NOT_INSTALLED: - installed_version_string = "" - if repo.installed_version: - installed_version_string = translate("AddonsInstaller", "Version") + " " - installed_version_string += repo.installed_version - else: - installed_version_string = ( - translate( - "AddonsInstaller", "Unknown version (no package.xml file found)" - ) - + " " - ) + version = repo.installed_version + date = "" + installed_version_string = "

" if repo.updated_timestamp: - installed_version_string += ( - " " + translate("AddonsInstaller", "installed on") + " " - ) - installed_version_string += ( + date = ( QDateTime.fromTime_t(repo.updated_timestamp) .date() .toString(Qt.SystemLocaleShortDate) ) - installed_version_string += ". " + if version and date: + installed_version_string += ( + translate( + "AddonsInstaller", f"Version {version} installed on {date}" + ) + + ". " + ) + elif version: + installed_version_string += ( + translate("AddonsInstaller", f"Version {version} installed") + ". " + ) + elif date: + installed_version_string += ( + translate("AddonsInstaller", f"Installed on {date}") + ". " + ) else: installed_version_string += ( - translate("AddonsInstaller", "installed") + ". " + translate("AddonsInstaller", "Installed") + ". " ) if repo.update_status == AddonManagerRepo.UpdateStatus.UPDATE_AVAILABLE: @@ -129,12 +134,20 @@ class PackageDetails(QWidget): ) installed_version_string += repo.metadata.Version installed_version_string += "." + elif repo.macro and repo.macro.version: + installed_version_string += ( + "" + + translate("AddonsInstaller", "Update available to version") + + " " + ) + installed_version_string += repo.macro.version + installed_version_string += "." else: installed_version_string += ( "" + translate( "AddonsInstaller", - "Update available to unknown version (no package.xml file found)", + "An update is available", ) + "." ) @@ -166,19 +179,32 @@ class PackageDetails(QWidget): + "." ) - basedir = FreeCAD.getUserAppDataDir() - moddir = os.path.join(basedir, "Mod", repo.name) - installed_version_string += ( - "
" - + translate("AddonsInstaller", "Installation location") - + ": " - + moddir + installed_version_string += "

" + self.ui.labelPackageDetails.setText(installed_version_string) + if repo.update_status == AddonManagerRepo.UpdateStatus.UPDATE_AVAILABLE: + self.ui.labelPackageDetails.setStyleSheet( + "color:" + utils.attention_color_string() + ) + else: + self.ui.labelPackageDetails.setStyleSheet( + "color:" + utils.bright_color_string() + ) + self.ui.labelPackageDetails.show() + + if repo.macro is not None: + moddir = FreeCAD.getUserMacroDir(True) + else: + basedir = FreeCAD.getUserAppDataDir() + moddir = os.path.join(basedir, "Mod", repo.name) + installationLocationString = ( + translate("AddonsInstaller", "Installation location") + ": " + moddir ) - self.ui.labelPackageDetails.setText(installed_version_string) - self.ui.labelPackageDetails.show() + self.ui.labelInstallationLocation.setText(installationLocationString) + self.ui.labelInstallationLocation.show() else: self.ui.labelPackageDetails.hide() + self.ui.labelInstallationLocation.hide() if repo.update_status == AddonManagerRepo.UpdateStatus.NOT_INSTALLED: self.ui.buttonInstall.show() @@ -206,14 +232,6 @@ class PackageDetails(QWidget): self.ui.buttonUpdate.hide() self.ui.buttonCheckForUpdate.hide() - warningColorString = "rgb(255,0,0)" - if hasattr(QApplication.instance(),"styleSheet"): - # Qt 5.9 doesn't give a QApplication instance, so can't give the stylesheet info - if "dark" in QApplication.instance().styleSheet().lower(): - warningColorString = "rgb(255,50,50)" - else: - warningColorString = "rgb(200,0,0)" - if repo.obsolete: self.ui.labelWarningInfo.show() self.ui.labelWarningInfo.setText( @@ -221,7 +239,9 @@ class PackageDetails(QWidget): + translate("AddonsInstaller", "WARNING: This addon is obsolete") + "" ) - self.ui.labelWarningInfo.setStyleSheet("color:" + warningColorString) + self.ui.labelWarningInfo.setStyleSheet( + "color:" + utils.warning_color_string() + ) elif repo.python2: self.ui.labelWarningInfo.show() self.ui.labelWarningInfo.setText( @@ -229,7 +249,9 @@ class PackageDetails(QWidget): + translate("AddonsInstaller", "WARNING: This addon is Python 2 Only") + "" ) - self.ui.labelWarningInfo.setStyleSheet("color:" + warningColorString) + self.ui.labelWarningInfo.setStyleSheet( + "color:" + utils.warning_color_string() + ) else: self.ui.labelWarningInfo.hide() @@ -416,6 +438,12 @@ class Ui_PackageDetails(object): self.verticalLayout_2.addWidget(self.labelPackageDetails) + self.labelInstallationLocation = QLabel(PackageDetails) + self.labelInstallationLocation.setTextInteractionFlags(Qt.TextSelectableByMouse) + self.labelInstallationLocation.hide() + + self.verticalLayout_2.addWidget(self.labelInstallationLocation) + self.labelWarningInfo = QLabel(PackageDetails) self.labelWarningInfo.hide() diff --git a/src/Mod/AddonManager/package_list.py b/src/Mod/AddonManager/package_list.py index 17a2a78e08..247f5bac91 100644 --- a/src/Mod/AddonManager/package_list.py +++ b/src/Mod/AddonManager/package_list.py @@ -358,6 +358,32 @@ class PackageListItemDelegate(QStyledItemDelegate): f"\n{maintainer['name']} <{maintainer['email']}>" ) self.widget.ui.labelMaintainer.setText(maintainers_string) + elif repo.macro and repo.macro.parsed: + self.widget.ui.labelDescription.setText(repo.macro.comment) + self.widget.ui.labelVersion.setText(repo.macro.version) + if repo.macro.date: + if repo.macro.version: + new_label = ( + "v" + + repo.macro.version + + ", " + + translate("AddonsInstaller", "updated") + + " " + + repo.macro.date + ) + else: + new_label = ( + translate("AddonsInstaller", "Updated") + " " + repo.macro.date + ) + self.widget.ui.labelVersion.setText(new_label) + if self.displayStyle == ListDisplayStyle.EXPANDED: + if repo.macro.author: + caption = translate("AddonsInstaller", "Author") + self.widget.ui.labelMaintainer.setText( + caption + ": " + repo.macro.author + ) + else: + self.widget.ui.labelMaintainer.setText("") else: self.widget.ui.labelDescription.setText("") self.widget.ui.labelVersion.setText("") @@ -533,6 +559,12 @@ class PackageListFilter(QSortFilterProxyModel): return True if re.match(desc).hasMatch(): return True + if ( + data.macro + and data.macro.comment + and re.match(data.macro.comment).hasMatch() + ): + return True return False else: return False