"""
This module provides functionality for managing plugins within the application.
The PluginManager class defined in this module handles the installation, removal, and management
of plugins within the application. It interacts with the PluginCollection class to load, install,
and uninstall plugins. Additionally, it provides methods for opening plugins, displaying help information,
and managing plugin-related UI elements.
Class:
PluginManager: Manages the plugins in the application.
"""
import os
import re
import shutil
import sys
import stat
from pathlib import Path
import webbrowser
import git
import requests
from PyQt6 import QtWidgets, QtGui, QtCore
from .control_plugin_collection import PluginCollection
[docs]
class PluginManager(QtWidgets.QMainWindow):
"""
Manages the plugins in the application.
The PluginManager class handles the installation, removal, and management of plugins within the application.
It provides methods for installing new plugins from a plugin store, opening installed plugins, removing plugins,
and displaying help information for plugins.
Attributes:
_controller (object): The main control object of the application.
_model (object): The model object of the application.
_stylesheet (object): The stylesheet object used for styling UI elements.
plugin (PluginCollection): An instance of the PluginCollection class for managing plugins.
_message_box (object): An instance of the MessageBoxHandler class for displaying messages.
_plugin_list (list): A list of available plugins retrieved from the plugin store.
apps_activated (str): The name of the currently activated plugin.
index (int): The index of the currently activated plugin.
widget (QWidget): The widget associated with the currently activated plugin.
text_title (str): The title of the currently activated plugin.
_main_config (dict): The main configuration settings of the application.
_github_config (dict): The GitHub configuration settings for accessing the plugin store.
_access_token (str): The access token for accessing the GitHub repository.
"""
def __init__(self, main_control):
super().__init__()
self._controller = main_control
self._model = main_control.model
self._stylesheet = main_control.model.get_stylesheet
self._controller.ui_object.delete_plugins_button.hide()
self._controller.ui_object.close_plugin_button.hide()
self.plugin = PluginCollection("plugins")
self._message_box = self._controller.ctrl_message_box
self._plugin_list = [None]
self.apps_activated = None
self.index = None
self.widget = None
self.text_title = None
self.init_available_plugin()
self._main_config = self._controller.model.main_config
self._github_config = self._main_config["Github_config"]
self._access_token = None
plugin_run = self._main_config["Plugin_run"]
self._controller.ui_object.label_plugin_name.hide()
self._controller.ui_object.open_in_new_window_plugins.hide()
if plugin_run is not None:
self.open_pinned_plugin(plugin_run)
self._controller.ui_object.add_plugins_button.hide()
self.connect_to_event()
[docs]
def connect_to_event(self):
"""
Connects the main window buttons to their corresponding actions.
This method is responsible for setting up event connections between the buttons in the main window and
their corresponding actions.
"""
self._controller.ui_object.delete_plugins_button.clicked.connect(self.delete_plugin_apps)
self._controller.ui_object.close_plugin_button.clicked.connect(self._controller.back_to_home)
self._controller.ui_object.help_plugins_button.clicked.connect(self.help_menu_plugin)
self._controller.ui_object.open_in_new_window_plugins.clicked.connect(self.open_plugins_in_new_window)
self._controller.ui_object.add_plugins_button.clicked.connect(self.get_list_plugins)
self._controller.ui_object.pushButton_close_plugin_store.clicked.connect(self.onclick_close_plugin_store)
[docs]
def onclick_close_plugin_store(self):
"""
Closes the plugin store.
"""
if self._model.debug_mode:
self._model.activity_logger.info("PluginManager: onclick_close_plugin_store(), "
"Action click close plugin store")
self._controller.ui_object.stackedWidget_2.setCurrentIndex(0)
[docs]
def clicked_plugin_remove(self):
"""
Delete a plugin application from the system.
The function prompts the user with a confirmation message, and if the user confirms, deletes the plugin
application from the system. The function then reloads the list of available plugins, initializes the available
plugin UI, and displays a success message.
"""
if self._model.debug_mode:
self._model.activity_logger.info("PluginManager: clicked_plugin_remove(), "
"Action click remove plugin from program")
sender = self.sender()
name = sender.accessibleName()
path_file = os.path.abspath(".")
path = path_file + '/plugins/' + name
message = "Are you sure you want to delete \n" + name + " application ?"
reply = self._message_box.display_ask_confirmation(message)
if reply is True:
self.remove_plugin_folder(path)
self.plugin.reload_plugins()
self.init_available_plugin()
self._message_box.display_message_box("Plugins were successfully deleted!", "information")
self._controller.ui_object.add_plugins_button.show()
self._controller.ctrl_plugin.get_list_plugins()
[docs]
def download_plugins_from_github(self, plugin):
"""
Download a plugin from GitHub.
Arg:
plugin (str): The name of the plugin to download.
"""
if self._model.debug_mode:
self._model.activity_logger.info("PluginManager: download_plugins_from_github(), "
f"Download plugin from GitHub : {plugin}")
access_token = self._github_config["token"]
path_file = os.path.abspath(".")
try:
repo_url = f'https://{access_token}@github.com/perseverance-tech-tw/{plugin}'
destination_path = os.path.join(path_file, 'plugins', plugin)
git.Repo.clone_from(repo_url, destination_path, branch='main')
try:
self._controller.ctrl_plugin.refresh_the_plugin_available()
except FileNotFoundError as e:
message = f"Error: {e}. Please check if the specified plugin exists."
self._message_box.display_message_box(message, "warning")
return None
except git.exc.GitCommandError as e:
message = f"Error: {e}. Failed to clone the plugin repository."
self._message_box.display_message_box(message, "warning")
return None
return None # No error occurred
except PermissionError as e:
message = f"Error: {e}. Permission denied while downloading the plugin."
self._message_box.display_message_box(message, "warning")
return None
[docs]
def get_list_plugins(self):
"""
Get the list of plugins from the plugin store on GitHub.
Retrieve the list of plugins from the GitHub repository and displays them in the plugin store UI.
"""
if self._model.debug_mode:
self._model.activity_logger.info("PluginManager: get_list_plugins(), "
"Get plugin name from plugin store github")
if self._github_config["token"] is None:
if self._controller.ctrl_message_box.show_update_dialog(
"No token found, input your token?") == QtWidgets.QMessageBox.StandardButton.Yes:
if self._controller.ctrl_github.open_dialog_for_input_token():
self._access_token = self._github_config["token"]
else:
return
else:
self._access_token = self._github_config["token"]
if self._access_token is not None:
try:
url = 'https://api.github.com/repos/perseverance-tech-tw/moilapp-plugins-store/contents?ref=main'
headers = {'Authorization': f'token {self._access_token}'}
response = requests.get(url, headers=headers, timeout=120) # Specify timeout here
response.raise_for_status() # Raise HTTPError for bad responses
file_names = [file['name'] for file in response.json() if file['size'] == 0]
self._plugin_list = file_names
self._controller.ui_object.stackedWidget_2.setCurrentIndex(1)
except (TypeError, requests.RequestException) as e: # Catch RequestException for network errors
message = f"Connection Error or repository not found!!!\n Error: {e}"
self._message_box.display_message_box(message, "warning")
return
self.clear_item_layout()
sys.path.append(os.path.dirname(__file__))
installed_apps = os.listdir("plugins")
for i, plugin_name in enumerate(self._plugin_list):
item_widget = QtWidgets.QWidget()
tuple_name = os.path.splitext(plugin_name)
name = tuple_name[0].replace('moilapp-plugin-', '').replace('_', ' ').replace('-', ' ').title()
line_text = QtWidgets.QLabel(name)
line_push_button = QtWidgets.QPushButton("Remove" if tuple_name[0] in installed_apps else "Install")
style = self._stylesheet.stylesheet_button_additional(
"remove" if tuple_name[0] in installed_apps else "install")
line_push_button.clicked.connect(
self.clicked_plugin_remove if tuple_name[0] in installed_apps else self.clicked_install_plugins)
line_push_button.setAccessibleName(tuple_name[0])
line_push_button.setStyleSheet(style)
line_push_button.setObjectName(str(i))
line_push_button.setMaximumWidth(70)
line_push_button.setMinimumWidth(70)
line_push_button.setMinimumHeight(30)
item_layout = QtWidgets.QHBoxLayout()
item_layout.setSpacing(5)
item_layout.addWidget(line_text)
item_layout.addWidget(line_push_button)
item_widget.setLayout(item_layout)
self._controller.ui_object.verticalLayout_plugin_store.addWidget(item_widget)
[docs]
def clicked_install_plugins(self):
"""
Handle the installation of a plugin.
This method is triggered when the user clicks the install button for a plugin. It retrieves the name
of the selected plugin and initiates the download process.
"""
if self._model.debug_mode:
self._model.activity_logger.info("PluginManager: clicked_install_plugins(), "
"Install plugin of MoilApp")
sender = self._controller.sender()
push_button = self._controller.findChild(QtWidgets.QPushButton, sender.objectName())
name = self._plugin_list[int(push_button.objectName())]
self.download_plugins_from_github(name)
self.get_list_plugins()
[docs]
def init_available_plugin(self):
"""
Initialize the available plugins by adding buttons for each one to the UI.
Clear any existing buttons from the UI layout, then iterates over the list of available plugins
and adds a button for each one. The icon for each plugin is retrieved using the `get_icon_` method
of the `PluginCollection` object.
Return:
None
"""
if self._model.debug_mode:
self._model.activity_logger.info("PluginManager: init_available_plugin(), "
"Add user interface of available plugin")
# self._controller.ui_object.add_plugins_button.hide()
for i in range(self._controller.ui_object.layout_application.count()):
self._controller.ui_object.layout_application.itemAt(i).widget().close()
for i, plugin_name in enumerate(self.plugin.name_application):
icon = self.plugin.get_icon_(i)
button = self.add_btn_apps_plugin(icon, plugin_name)
button.clicked.connect(self.open_plugin_apps)
self._controller.ui_object.layout_application.addWidget(button)
[docs]
def install_new_plugin(self):
"""
Open a file dialog for selecting a new plugin folder and installs it into the plugin store.
"""
if self._model.debug_mode:
self._model.activity_logger.info("PluginManager: install_new_plugin(), "
"Install new plugin and manage conflict plugin name")
options = QtWidgets.QFileDialog.Option.DontUseNativeDialog
dir_plugin = QtWidgets.QFileDialog.getExistingDirectory(None,
'Select Application Folder', "../plugin_store", options)
if dir_plugin:
original = dir_plugin
name_plug = os.path.basename(os.path.normpath(original))
path_file = os.path.dirname(os.path.realpath(__file__))
target = path_file + '/plugins/'
name_exist = Path(target + name_plug)
if name_exist.exists():
QtWidgets.QMessageBox.warning(None, "Warning !!", "Plugins already exist!!")
else:
list_app = self.plugin.name_application
self._controller.model.copy_directory(original, target)
self.refresh_plugin_available(list_app)
[docs]
def refresh_the_plugin_available(self):
"""
Refresh the list of available plugins and adds new ones to the UI.
Retrieve the list of available plugins, compares it with the previous list,
and adds any newly available plugins to the UI. Also displays a message box
to inform the user about the successful addition of plugins.
Return:
None
"""
if self._model.debug_mode:
self._model.activity_logger.info("PluginManager: refresh_the_plugin_available(), "
"Get list of available plugin")
list_app = self.plugin.name_application
self.refresh_plugin_available(list_app)
[docs]
def refresh_plugin_available(self, list_app):
"""
Refresh the list of available plugins.
Arg:
list_app (list): The previous list of available plugins.
Return:
None
"""
if self._model.debug_mode:
self._model.activity_logger.info("PluginManager: refresh_plugin_available(), "
"Refresh available plugin")
self.plugin.reload_plugins()
new_list = self.plugin.name_application
name = [item for item in new_list if item not in list_app]
def list_to_string(list_in):
return " ".join(list_in)
index = new_list.index(list_to_string(name))
icon = self.plugin.get_icon_(index)
button = self.add_btn_apps_plugin(icon, self.plugin.name_application[index])
button.clicked.connect(self.open_plugin_apps)
self._controller.ui_object.layout_application.addWidget(button)
self._message_box.display_message_box("Plugins was successfully added!", "information")
[docs]
def open_pinned_plugin(self, index):
"""
Open the pinned plugin with the specified index.
Arg:
index (int): The index of the pinned plugin to be opened.
Return:
None
"""
if self._model.debug_mode:
self._model.activity_logger.info("PluginManager: open_pinned_plugin(), "
"Open pinned plugin active")
try:
self.index = index
if self._controller.timer.isActive():
self._controller.timer.stop()
self._controller.ui_object.delete_plugins_button.show()
self._controller.ui_object.close_plugin_button.show()
for i in range(self._controller.ui_object.layout_plugin.count()):
self._controller.ui_object.layout_plugin.itemAt(i).widget().close()
self.widget = self.plugin.get_widget(self.index, self._controller.model)
self.show_plugins_after_installation(self.widget)
self.text_title = ' '.join(''.join(sent) for sent in re.findall('.[^A-Z]*',
self.plugin.name_application[self.index]))
if self.text_title is not None or self.text_title != "":
self._controller.ui_object.label_plugin_name.setText(self.text_title)
self.apps_activated = self.plugin.name_application[self.index]
except IndexError:
# Handle IndexError, which may occur if 'index' is out of range
# Handle it by resetting the plugin configuration
self._main_config["Plugin_run"] = None
self._model.save_main_config_update()
self._controller.ui_object.delete_plugins_button.hide()
self._controller.ui_object.label_plugin_name.hide()
self._controller.ui_object.close_plugin_button.hide()
self._controller.ui_object.open_in_new_window_plugins.hide()
self.index = None
[docs]
def show_plugins_after_installation(self, widget):
"""
Show the installed plugin in the UI after installation.
Arg:
widget: The widget representing the installed plugin.
Return:
None
"""
if self._model.debug_mode:
self._model.activity_logger.info("PluginManager: show_plugins_after_installation(), "
"Open pinned plugin active")
self._controller.ui_object.layout_plugin.addWidget(widget)
self._controller.ui_object.widget_container_content.setCurrentIndex(1)
self._controller.ui_object.frame_btn_moilapp.hide()
self._controller.ui_object.frame_button_view.hide()
self._controller.ui_object.label_plugin_name.show()
self._controller.ui_object.open_in_new_window_plugins.show()
[docs]
def open_plugin_apps(self):
"""
Open the selected plugin application and displays its widget in the plugin layout.
Raise:
IndexError: If the selected plugin index is not found in `self.plugin.name_application`.
Side Effects:
- Sets `self.index` to the index of the selected plugin.
- Shows the "Delete Plugins" and "Close Plugin" buttons in the main control UI.
- Closes all widgets in the plugin layout before adding the selected plugin's widget.
- Changes the current widget container to the plugin layout.
- Hides the "MOIL App" and "View" buttons in the main control UI.
- Sets `self.apps_activated` to the selected plugin's name.
"""
if self._model.debug_mode:
self._model.activity_logger.info("PluginManager: open_plugin_apps(), "
"Open plugin app to windows")
button = self._controller.sender()
# button.setStyleSheet("background-color: rgb(25,25,25)")
index = self.plugin.name_application.index(button.objectName())
if index != self.index:
if self._controller.timer.isActive():
self._controller.timer.stop()
self.index = self.plugin.name_application.index(button.objectName())
self._controller.ui_object.delete_plugins_button.show()
self._controller.ui_object.close_plugin_button.show()
self._controller.ui_object.add_plugins_button.hide()
for i in range(self._controller.ui_object.layout_plugin.count()):
self._controller.ui_object.layout_plugin.itemAt(i).widget().close()
if self.plugin.set_always_pop_up(self.index):
self.open_plugins_in_new_window()
else:
self.widget = self.plugin.get_widget(self.index, self._controller.model)
self.show_plugins_after_installation(self.widget)
self.text_title = ' '.join(''.join(sent) for sent in re.findall('.[^A-Z]*', button.objectName()))
if self.text_title is not None or self.text_title != "":
self._controller.ui_object.label_plugin_name.setText(self.text_title)
self.apps_activated = button.objectName()
self._main_config["Plugin_run"] = self.index
self._controller.model.save_main_config_update()
else:
self.widget.raise_()
if self.widget.isMinimized():
self.widget.showMaximized()
QtWidgets.QMessageBox.information(self._controller, "Information", "Plugins already opened!!")
[docs]
def open_plugins_in_new_window(self):
"""
Open the plugin application in a new window.
Return:
None
"""
if self._model.debug_mode:
self._model.activity_logger.info("PluginManager: open_plugins_in_new_window(), "
"Open plugin app to other (new) windows")
self._controller.ui_object.add_plugins_button.show()
self._controller.ui_object.stackedWidget_2.setCurrentIndex(0)
for i in range(self._controller.ui_object.layout_plugin.count()):
self._controller.ui_object.layout_plugin.itemAt(i).widget().close()
self.widget = self.plugin.get_widget(self.index, self._controller.model)
def close_event(event):
message_box = QtWidgets.QMessageBox(None)
reply = message_box.question(None, 'Question?', 'Are you sure you want to quit?',
QtWidgets.QMessageBox.StandardButton.Yes |
QtWidgets.QMessageBox.StandardButton.No,
QtWidgets.QMessageBox.StandardButton.No)
if reply == QtWidgets.QMessageBox.StandardButton.Yes:
event.accept()
self.index = None
else:
event.ignore()
self.widget.closeEvent = close_event
self.widget.setWindowTitle(self.text_title)
self.widget.show()
self._controller.ui_object.widget_container_content.setCurrentIndex(0)
self._controller.ui_object.frame_btn_moilapp.show()
self._controller.ui_object.frame_button_view.show()
self._controller.ui_object.delete_plugins_button.hide()
self._controller.ui_object.label_plugin_name.hide()
self._controller.ui_object.close_plugin_button.hide()
self._controller.ui_object.open_in_new_window_plugins.hide()
[docs]
def add_btn_apps_plugin(self, icon_, name):
"""
Create and return a QPushButton widget with an icon and a name.
Args:
icon_: A string specifying the path of the icon file.
name: A string specifying the name of the plugin.
Return:
A QPushButton widget with the specified icon and name.
"""
if self._model.debug_mode:
self._model.activity_logger.info("PluginManager: add_btn_apps_plugin(), "
"Open plugin button with icon")
button = QtWidgets.QPushButton()
button.setObjectName(name)
size_policy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Minimum,
QtWidgets.QSizePolicy.Policy.Expanding)
size_policy.setHorizontalStretch(0)
size_policy.setVerticalStretch(0)
size_policy.setHeightForWidth(button.sizePolicy().hasHeightForWidth())
button.setSizePolicy(size_policy)
button.setMinimumSize(QtCore.QSize(40, 25))
button.setMaximumSize(QtCore.QSize(35, 16777215))
text_title = ' '.join(''.join(sent) for sent in re.findall('.[^A-Z]*', name))
button.setToolTip(text_title)
button.setStatusTip(text_title)
button.setIconSize(QtCore.QSize(30, 30))
if icon_ is not None:
if self._controller.model.theme_mode == "light":
icon = QtGui.QIcon(icon_)
button.setIcon(icon)
else:
# auto generate invert color image under developing
icon = QtGui.QIcon(icon_)
button.setIcon(icon)
return button
[docs]
def clear_item_layout(self):
"""
Clear the items from the layout.
Remove all items from the vertical layout 'verticalLayout_plugin_store' in the UI.
Return:
None
"""
if self._model.debug_mode:
self._model.activity_logger.info("PluginManager: clear_item_layout(), "
"Clear item layout")
while self._controller.ui_object.verticalLayout_plugin_store.count():
item = self._controller.ui_object.verticalLayout_plugin_store.takeAt(0)
widget = item.widget()
widget.deleteLater()
[docs]
def delete_plugin_apps(self):
"""
Delete a plugin application from the system.
The function prompts the user with a confirmation message, and if the user confirms, deletes the plugin
application from the system. The function then reloads the list of available plugins, initializes the available
plugin UI, and displays a success message.
"""
if self._model.debug_mode:
self._model.activity_logger.info("PluginManager: delete_plugin_apps(), "
"Delete plugin apps selected")
index = self.plugin.name_application.index(self.apps_activated)
name = self.plugin.name_application[index]
path = self.plugin.path_folder[index]
path = path.split(".")[1]
# path_file = os.path.dirname(os.path.realpath(__file__))
path_file = os.path.abspath(".")
path = path_file + '/plugins/' + path
reply = QtWidgets.QMessageBox.question(None, 'Message',
"Are you sure want to delete \n" +
name + " application ?\n",
QtWidgets.QMessageBox.StandardButton.Yes |
QtWidgets.QMessageBox.StandardButton.No,
QtWidgets.QMessageBox.StandardButton.No)
if reply == QtWidgets.QMessageBox.StandardButton.Yes:
self.remove_plugin_folder(path)
self.plugin.reload_plugins()
self.init_available_plugin()
self._message_box.display_message_box("Plugins was successfully deleted!", "information")
self._controller.back_to_home()
[docs]
def remove_plugin_folder(self, path):
"""
Remove a plugin folder from the directory.
Arg:
path (str): The path to the plugin folder.
Return:
None
"""
if self._model.debug_mode:
self._model.activity_logger.info("PluginManager: remove_plugin_folder(), "
"Remove plugin folder from directory")
try:
shutil.rmtree(path, ignore_errors=False, onerror=self.handle_remove_readonly)
except FileNotFoundError:
print(f"Folder not found: {path}")
except PermissionError:
print(f"Permission denied while deleting folder: {path}")
except OSError as e:
print(f"Error occurred while deleting folder: {e}")
[docs]
@classmethod
def handle_remove_readonly(cls, func, path, _):
"""
Handle the removal of read-only files or directories.
This method is used as an error handler when attempting to remove read-only files or directories.
It clears the read-only attribute of the file or directory and attempts to delete it again.
Args:
func (callable): The function used to remove the file or directory.
path (str): The path to the read-only file or directory.
_ (object): Ignored parameter required by the shutil.rmtree function.
Return:
None
"""
# Clear the read-only attribute and attempt to delete the file again
os.chmod(path, stat.S_IWRITE)
func(path)