From f91454c56a1dd869f59e9d3fb6fb196b1ce7f3ac Mon Sep 17 00:00:00 2001 From: Maxim Kizub Date: Sun, 6 Apr 2025 16:39:15 +0300 Subject: [PATCH] Support dynamic plugin install/enable/disable --- EDMarketConnector.py | 138 ++++++++++++----------- plug.py | 120 ++++++++++++++------ prefs.py | 257 ++++++++++++++++++++++++++++--------------- 3 files changed, 327 insertions(+), 188 deletions(-) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index 28e83686..233a3e87 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -441,6 +441,7 @@ class AppWindow: self.minimizing = False self.w.rowconfigure(0, weight=1) self.w.columnconfigure(0, weight=1) + self.plugin_id = 0 # companion needs to be able to send <> events companion.session.set_tk_master(self.w) @@ -478,6 +479,7 @@ class AppWindow: frame = tk.Frame(self.w, name=appname.lower()) frame.grid(sticky=tk.NSEW) frame.columnconfigure(1, weight=1) + self.frame = frame self.cmdr_label = tk.Label(frame, name='cmdr_label') self.cmdr = tk.Label(frame, compound=tk.RIGHT, anchor=tk.W, name='cmdr') @@ -517,38 +519,11 @@ class AppWindow: self.station.grid(row=ui_row, column=1, sticky=tk.EW) ui_row += 1 - plugin_no = 0 + self.all_plugin_frame = tk.Frame(frame, name=f"all_plugin_frame") + self.all_plugin_frame.grid(row=ui_row, columnspan=2, sticky=tk.NSEW) + self.insert_no_plugins_label() for plugin in plug.PLUGINS: - # Per plugin separator - plugin_sep = tk.Frame( - frame, highlightthickness=1, name=f"plugin_hr_{plugin_no + 1}" - ) - # Per plugin frame, for it to use as its parent for own widgets - plugin_frame = tk.Frame( - frame, - name=f"plugin_{plugin_no + 1}" - ) - appitem = plugin.get_app(plugin_frame) - if appitem: - plugin_no += 1 - plugin_sep.grid(columnspan=2, sticky=tk.EW) - ui_row = frame.grid_size()[1] - plugin_frame.grid( - row=ui_row, columnspan=2, sticky=tk.NSEW - ) - plugin_frame.columnconfigure(1, weight=1) - if isinstance(appitem, tuple) and len(appitem) == 2: - ui_row = frame.grid_size()[1] - appitem[0].grid(row=ui_row, column=0, sticky=tk.W) - appitem[1].grid(row=ui_row, column=1, sticky=tk.EW) - - else: - appitem.grid(columnspan=2, sticky=tk.EW) - - else: - # This plugin didn't provide any UI, so drop the frames - plugin_frame.destroy() - plugin_sep.destroy() + self.show_plugin(plugin) # LANG: Update button in main window self.button = ttk.Button( @@ -599,7 +574,7 @@ class AppWindow: self.file_menu = self.view_menu = tk.Menu(self.menubar, tearoff=tk.FALSE) self.file_menu.add_command(command=lambda: stats.StatsDialog(self.w, self.status)) self.file_menu.add_command(command=self.save_raw) - self.file_menu.add_command(command=lambda: prefs.PreferencesDialog(self.w, self.postprefs)) + self.file_menu.add_command(command=lambda: prefs.PreferencesDialog(self, self.w, self.postprefs)) self.file_menu.add_separator() self.file_menu.add_command(command=self.onexit) self.menubar.add_cascade(menu=self.file_menu) @@ -753,6 +728,69 @@ class AppWindow: else: self.w.wm_iconify() + def show_plugin(self, plugin: plug.Plugin): + self.remove_no_plugins_label() + plugin_id = self.plugin_id + 1 + # Per plugin separator + plugin_sep = tk.Frame( + self.all_plugin_frame, highlightthickness=1, name=f"plugin_hr_{plugin_id}" + ) + # Per plugin frame, for it to use as its parent for own widgets + plugin_frame = tk.Frame(self.all_plugin_frame, name=f"plugin_{plugin_id}") + appitem = plugin.get_app(plugin_frame) + if appitem: + self.plugin_id = plugin_id + plugin.frame_id = plugin_id + ui_row = self.all_plugin_frame.grid_size()[1] + plugin_sep.grid(row=ui_row, columnspan=2, sticky=tk.EW) + ui_row = self.all_plugin_frame.grid_size()[1] + plugin_frame.grid(row=ui_row, columnspan=2, sticky=tk.NSEW) + plugin_frame.columnconfigure(1, weight=1) + if isinstance(appitem, tuple) and len(appitem) == 2: + ui_row = self.all_plugin_frame.grid_size()[1] + appitem[0].grid(row=ui_row, column=0, sticky=tk.W) + appitem[1].grid(row=ui_row, column=1, sticky=tk.EW) + else: + appitem.grid(row=ui_row, columnspan=2, sticky=tk.EW) + else: + # This plugin didn't provide any UI, so drop the frames + plugin_frame.destroy() + plugin_sep.destroy() + self.insert_no_plugins_label() + + def hide_plugin(self, plugin: plug.Plugin): + if plugin and plugin.frame_id > 0: + if widget := self.all_plugin_frame.nametowidget(f"plugin_hr_{plugin.frame_id}"): + widget.grid_forget() + widget.destroy() + if widget := self.all_plugin_frame.nametowidget(f"plugin_{plugin.frame_id}"): + widget.grid_forget() + widget.destroy() + plugin.frame_id = 0 + self.insert_no_plugins_label() + self.frame.pack() + + def remove_no_plugins_label(self): + try: + if label := self.all_plugin_frame.nametowidget("no_plugins_label"): + label.grid_forget() + label.destroy() + except Exception: + pass + + def insert_no_plugins_label(self): + try: + if self.all_plugin_frame.nametowidget("no_plugins_label"): + return + except Exception: + pass + children = self.all_plugin_frame.winfo_children() + if len(children) == 0: + label = tk.Label(self.all_plugin_frame, text="No plugins", name="no_plugins_label", anchor=tk.CENTER) + pady = 2 if sys.platform != 'win32' else 0 + label.grid(row=0, columnspan=2, sticky=tk.NSEW, padx=self.PADX, pady=pady) + return + def update_suit_text(self) -> None: """Update the suit text for current type and loadout.""" if not monitor.state['Odyssey']: @@ -2261,7 +2299,7 @@ sys.path: {sys.path}''' def messagebox_broken_plugins(): """Display message about 'broken' plugins that failed to load.""" - if plug.PLUGINS_broken: + if [x for x in plug.PLUGINS if x.broken]: # LANG: Popup-text about 'broken' plugins that failed to load popup_text = tr.tl( "One or more of your enabled plugins failed to load. Please see the list on the '{PLUGINS}' " @@ -2288,38 +2326,6 @@ sys.path: {sys.path}''' parent=root ) - def messagebox_not_py3(): - """Display message about plugins not updated for Python 3.x.""" - plugins_not_py3_last = config.get_int('plugins_not_py3_last', default=0) - if (plugins_not_py3_last + 86400) < int(time()) and plug.PLUGINS_not_py3: - # LANG: Popup-text about 'active' plugins without Python 3.x support - popup_text = tr.tl( - "One or more of your enabled plugins do not yet have support for Python 3.x. Please see the " - "list on the '{PLUGINS}' tab of '{FILE}' > '{SETTINGS}'. You should check if there is an " - "updated version available, else alert the developer that they need to update the code for " - r"Python 3.x.\r\n\r\nYou can disable a plugin by renaming its folder to have '{DISABLED}' on " - "the end of the name." - ) - - # Substitute in the other words. - popup_text = popup_text.format( - PLUGINS=tr.tl('Plugins'), # LANG: Settings > Plugins tab - FILE=tr.tl('File'), # LANG: 'File' menu - SETTINGS=tr.tl('Settings'), # LANG: File > Settings - DISABLED='.disabled' - ) - # And now we do need these to be actual \r\n - popup_text = popup_text.replace('\\n', '\n') - popup_text = popup_text.replace('\\r', '\r') - - tk.messagebox.showinfo( - # LANG: Popup window title for list of 'enabled' plugins that don't work with Python 3.x - tr.tl('EDMC: Plugins Without Python 3.x Support'), - popup_text, - parent=root - ) - config.set('plugins_not_py3_last', int(time())) - # UI Transparency ui_transparency = config.get_int('ui_transparency') if ui_transparency == 0: @@ -2328,8 +2334,6 @@ sys.path: {sys.path}''' root.wm_attributes('-alpha', ui_transparency / 100) # Display message box about plugins that failed to load root.after(0, messagebox_broken_plugins) - # Display message box about plugins without Python 3.x support - root.after(1, messagebox_not_py3) # Show warning popup for killswitches matching current version root.after(2, show_killswitch_poppup, root) # Start the main event loop diff --git a/plug.py b/plug.py index 75f9eaf4..b101b07f 100644 --- a/plug.py +++ b/plug.py @@ -28,8 +28,6 @@ logger = get_main_logger() # List of loaded Plugins PLUGINS = [] -PLUGINS_not_py3 = [] -PLUGINS_broken = [] # For asynchronous error display @@ -49,7 +47,7 @@ last_error = LastError() class Plugin: """An EDMC plugin.""" - def __init__(self, name: str, loadfile: Path | None, plugin_logger: logging.Logger | None): # noqa: CCR001 + def __init__(self, name: str, folder: str, loadfile: Path | None, plugin_logger: logging.Logger | None): # noqa: CCR001 """ Load a single plugin. @@ -59,9 +57,11 @@ class Plugin: :raises Exception: Typically, ImportError or OSError """ self.name: str = name # Display name. - self.folder: str | None = name # basename of plugin folder. None for internal plugins. + self.folder: str | None = folder # basename of plugin folder. None for internal plugins. self.module = None # None for disabled plugins. self.logger: logging.Logger | None = plugin_logger + self.frame_id = 0 # Not 0 is shown in main window + self.broken: str = None # Error message if loadfile: logger.info(f'loading plugin "{name.replace(".", "_")}" from "{loadfile}"') @@ -78,14 +78,18 @@ class Plugin: newname = module.plugin_start3(Path(loadfile).resolve().parent) self.name = str(newname) if newname else self.name self.module = module + self.broken = None elif getattr(module, 'plugin_start', None): + self.broken = f'plugin {name} needs migrating' logger.warning(f'plugin {name} needs migrating\n') - PLUGINS_not_py3.append(self) else: + self.broken = 'needs python 3 migration' logger.error(f'plugin {name} has no plugin_start3() function') else: + self.broken = 'Failed to load from load.py' logger.error(f'Failed to load Plugin "{name}" from file "{loadfile}"') - except Exception: + except Exception as e: + self.broken = f'Error: {e}' logger.exception(f': Failed for Plugin "{name}"') raise else: @@ -164,8 +168,7 @@ def load_plugins(master: tk.Tk) -> None: # Add plugin folder to load path so packages can be loaded from plugin folder sys.path.append(config.plugin_dir) - found = _load_found_plugins() - PLUGINS.extend(sorted(found, key=lambda p: operator.attrgetter('name')(p).lower())) + sync_all_plugins() def _load_internal_plugins(): @@ -175,16 +178,42 @@ def _load_internal_plugins(): try: plugin_name = name[:-3] plugin_path = config.internal_plugin_dir_path / name - plugin = Plugin(plugin_name, plugin_path, logger) - plugin.folder = None + plugin = Plugin(plugin_name, None, plugin_path, logger) internal.append(plugin) except Exception: logger.exception(f'Failure loading internal Plugin "{name}"') return internal -def _load_found_plugins(): - found = [] +def load_plugin(folder: str): + if not (config.plugin_dir_path / folder).is_dir() or folder.startswith(('.', '_')): + return None + if folder.endswith('.disabled'): + return None + + disabled_list = config.get_list('disabled_plugins', default=[]) + if disabled_list.count(folder) > 0: + plugin = Plugin(folder, folder, None, logger) + else: + try: + # Add plugin's folder to load path in case plugin has internal package dependencies + sys.path.append(str(config.plugin_dir_path / folder)) + import EDMCLogging + # Create a logger for this 'found' plugin. Must be before the load.py is loaded. + plugin_logger = EDMCLogging.get_plugin_logger(folder) + plugin = Plugin(folder, folder, config.plugin_dir_path / folder / 'load.py', plugin_logger) + except Exception as e: + plugin = Plugin(folder, folder, None, logger) + plugin.broken = f'Error: {e}' + logger.exception(f'Failure loading found Plugin "{folder}"') + old = get_plugin_by_folder(folder) + if old: + disable_plugin(old, False) + PLUGINS.remove(old) + PLUGINS.append(plugin) + return plugin + +def sync_all_plugins(): # Load any plugins that are also packages first, but note it's *still* # 100% relying on there being a `load.py`, as only that will be loaded. # The intent here is to e.g. have EDMC-Overlay load before any plugins @@ -194,27 +223,14 @@ def _load_found_plugins(): not (p / '__init__.py').is_file(), p.name.lower())) for plugin_file in plugin_files: - name = plugin_file.name - if not (config.plugin_dir_path / name).is_dir() or name.startswith(('.', '_')): - pass - elif name.endswith('.disabled'): - name, discard = name.rsplit('.', 1) - found.append(Plugin(name, None, logger)) - else: - try: - # Add plugin's folder to load path in case plugin has internal package dependencies - sys.path.append(str(config.plugin_dir_path / name)) - - import EDMCLogging - # Create a logger for this 'found' plugin. Must be before the load.py is loaded. - plugin_logger = EDMCLogging.get_plugin_logger(name) - found.append(Plugin(name, config.plugin_dir_path / name / 'load.py', plugin_logger)) - except Exception: - PLUGINS_broken.append(Plugin(name, None, logger)) - logger.exception(f'Failure loading found Plugin "{name}"') - pass - return found + if not get_plugin_by_folder(plugin_file.name): + load_plugin(plugin_file.name) + for plugin in list(PLUGINS): + if plugin.folder and not os.path.exists(os.path.join(config.plugin_dir_path, plugin.folder)): + disable_plugin(plugin, False) + plugin.broken = f'Missing folder: {plugin.folder}' + PLUGINS.remove(plugin) def provides(fn_name: str) -> list[str]: """ @@ -381,7 +397,7 @@ def notify_journal_entry_cqc( return error -def notify_dashboard_entry(cmdr: str, is_beta: bool, entry: MutableMapping[str, Any],) -> str | None: +def notify_dashboard_entry(cmdr: str, is_beta: bool, entry: MutableMapping[str, Any], ) -> str | None: """ Send a status entry to each plugin. @@ -468,3 +484,41 @@ def show_error(err: str) -> None: if err and last_error.root: last_error.msg = str(err) last_error.root.event_generate('<>', when="tail") + + +def enable_plugin(plugin: Plugin, add_to_config = True) -> Plugin: + if add_to_config: + disabled_list = config.get_list('disabled_plugins', default=[]) + if disabled_list.count(plugin.folder) > 0: + disabled_list.remove(plugin.folder) + config.set('disabled_plugins', disabled_list) + if loaded := load_plugin(plugin.folder): + if PLUGINS.count(plugin): + PLUGINS.remove(plugin) + PLUGINS.append(loaded) + return loaded + return plugin + + +def disable_plugin(plugin: Plugin, add_to_config = True) -> Plugin: + if add_to_config: + disabled_list = config.get_list('disabled_plugins', default=[]) + if disabled_list.count(plugin.folder) == 0: + disabled_list.append(plugin.folder) + config.set('disabled_plugins', disabled_list) + if plugin.module: + plugin_stop = plugin._get_func('plugin_stop') + if plugin_stop: + try: + logger.info(f'Asking plugin "{plugin.name}" to stop...') + plugin_stop() + except Exception: + logger.exception(f'Plugin "{plugin.name}" failed') + plugin.module = None + return plugin + +def get_plugin_by_folder(folder: str) -> Plugin | None: + for plugin in PLUGINS: + if plugin.folder == folder: + return plugin + return None diff --git a/prefs.py b/prefs.py index 95f29e7f..55bb6ec5 100644 --- a/prefs.py +++ b/prefs.py @@ -4,6 +4,8 @@ from __future__ import annotations import contextlib import logging +import os.path +import zipfile from os.path import expandvars, join, normpath from pathlib import Path import subprocess @@ -12,7 +14,7 @@ import tkinter as tk import warnings from os import system from tkinter import colorchooser as tkColorChooser # type: ignore # noqa: N812 -from tkinter import ttk +from tkinter import ttk, messagebox from types import TracebackType from typing import Any, Callable, Optional, Type import myNotebook as nb # noqa: N813 @@ -228,9 +230,10 @@ if sys.platform == 'win32': class PreferencesDialog(tk.Toplevel): """The EDMC preferences dialog.""" - def __init__(self, parent: tk.Tk, callback: Optional[Callable]): + def __init__(self, app, parent: tk.Tk, callback: Optional[Callable]): super().__init__(parent) + self.theApp = app self.parent = parent self.callback = callback self.req_restart = False @@ -265,6 +268,7 @@ class PreferencesDialog(tk.Toplevel): notebook: nb.Notebook = nb.Notebook(frame) notebook.bind('<>', self.tabchanged) # Recompute on tab change + self.notebook = notebook self.PADX = 10 self.BUTTONX = 12 # indent Checkbuttons and Radiobuttons @@ -409,6 +413,7 @@ class PreferencesDialog(tk.Toplevel): for plugin in plug.PLUGINS: plugin_frame = plugin.get_prefs(notebook, monitor.cmdr, monitor.is_beta) if plugin_frame: + plugin.notebook_frame = plugin_frame notebook.add(plugin_frame, text=plugin.name) def __setup_config_tab(self, notebook: ttk.Notebook) -> None: # noqa: CCR001 @@ -905,7 +910,10 @@ class PreferencesDialog(tk.Toplevel): def __setup_plugin_tab(self, notebook: ttk.Notebook) -> None: # noqa: CCR001 # Plugin settings and info plugins_frame = nb.Frame(notebook) - plugins_frame.columnconfigure(0, weight=1) + plugins_frame.columnconfigure(0, weight=2) + plugins_frame.columnconfigure(1, weight=1) + plugins_frame.columnconfigure(2, weight=1) + plugins_frame.columnconfigure(3, weight=1) row = AutoInc(start=0) self.plugdir = tk.StringVar() self.plugdir.set(str(config.get_str('plugin_dir'))) @@ -916,13 +924,14 @@ class PreferencesDialog(tk.Toplevel): textvariable=self.plugdir) # Link StringVar to Entry widget self.plugdir_entry.grid(columnspan=4, padx=self.PADX, pady=self.BOXY, sticky=tk.EW, row=row.get()) with row as cur_row: - nb.Label( + # Install Plugin Button + self.install_plugin_btn = ttk.Button( plugins_frame, - # Help text in settings - # LANG: Tip/label about how to disable plugins - text=tr.tl( - "Tip: You can disable a plugin by{CR}adding '{EXT}' to its folder name").format(EXT='.disabled') - ).grid(columnspan=1, padx=self.PADX, pady=self.PADY, sticky=tk.EW, row=cur_row) + # LANG: Label on button used to Install Plugin + text=tr.tl('Install Plugin'), + command=lambda frame=plugins_frame: self.installbrowse(config.plugin_dir_path, frame) + ) + self.install_plugin_btn.grid(column=0, padx=self.PADX, pady=self.PADY, sticky=tk.EW, row=cur_row) # Open Plugin Folder Button self.open_plug_folder_btn = ttk.Button( @@ -953,90 +962,110 @@ class PreferencesDialog(tk.Toplevel): state=tk.NORMAL if config.get_str('plugin_dir') else tk.DISABLED ).grid(column=3, padx=self.PADX, pady=self.PADY, sticky=tk.EW, row=cur_row) - enabled_plugins = list(filter(lambda x: x.folder and x.module, plug.PLUGINS)) - if enabled_plugins: - ttk.Separator(plugins_frame, orient=tk.HORIZONTAL).grid( - columnspan=4, padx=self.PADX, pady=self.SEPY, sticky=tk.EW, row=row.get() - ) - nb.Label( - plugins_frame, - # LANG: Label on list of enabled plugins - text=tr.tl('Enabled Plugins')+':' # List of plugins in settings - ).grid(padx=self.PADX, pady=self.PADY, sticky=tk.W, row=row.get()) + ttk.Separator(plugins_frame, orient=tk.HORIZONTAL).grid( + columnspan=4, padx=self.PADX, pady=self.SEPY, sticky=tk.EW, row=row.get() + ) + nb.Label( + plugins_frame, + # LANG: Label on list of enabled plugins + text=tr.tl('Enabled Plugins')+':' # List of plugins in settings + ).grid(padx=self.PADX, pady=self.PADY, sticky=tk.W, row=row.get()) - for plugin in enabled_plugins: - if plugin.name == plugin.folder: - label = nb.Label(plugins_frame, text=plugin.name) - - else: - label = nb.Label(plugins_frame, text=f'{plugin.folder} ({plugin.name})') - - label.grid(columnspan=2, padx=self.LISTX, pady=self.PADY, sticky=tk.W, row=row.get()) - - ############################################################ - # Show which plugins don't have Python 3.x support - ############################################################ - if plug.PLUGINS_not_py3: - ttk.Separator(plugins_frame, orient=tk.HORIZONTAL).grid( - columnspan=3, padx=self.PADX, pady=self.SEPY, sticky=tk.EW, row=row.get() - ) - # LANG: Plugins - Label for list of 'enabled' plugins that don't work with Python 3.x - nb.Label(plugins_frame, text=tr.tl('Plugins Without Python 3.x Support')+':').grid( - padx=self.PADX, pady=self.PADY, sticky=tk.W, row=row.get() - ) - - HyperlinkLabel( - # LANG: Plugins - Label on URL to documentation about migrating plugins from Python 2.7 - plugins_frame, text=tr.tl('Information on migrating plugins'), - background=nb.Label().cget('background'), - url='https://github.com/EDCD/EDMarketConnector/blob/main/PLUGINS.md#migration-from-python-27', - underline=True - ).grid(columnspan=2, padx=self.PADX, pady=self.PADY, sticky=tk.W, row=row.get()) - - for plugin in plug.PLUGINS_not_py3: - if plugin.folder: # 'system' ones have this set to None to suppress listing in Plugins prefs tab - nb.Label(plugins_frame, text=plugin.name).grid( - columnspan=2, padx=self.LISTX, pady=self.PADY, sticky=tk.W, row=row.get() - ) - ############################################################ - # Show disabled plugins - ############################################################ - disabled_plugins = list(filter(lambda x: x.folder and not x.module, plug.PLUGINS)) - if disabled_plugins: - ttk.Separator(plugins_frame, orient=tk.HORIZONTAL).grid( - columnspan=3, padx=self.PADX, pady=self.SEPY, sticky=tk.EW, row=row.get() - ) - nb.Label( - plugins_frame, - # LANG: Label on list of user-disabled plugins - text=tr.tl('Disabled Plugins')+':' # List of plugins in settings - ).grid(padx=self.PADX, pady=self.PADY, sticky=tk.W, row=row.get()) - - for plugin in disabled_plugins: - nb.Label(plugins_frame, text=plugin.name).grid( - columnspan=2, padx=self.LISTX, pady=self.PADY, sticky=tk.W, row=row.get() - ) - ############################################################ - # Show plugins that failed to load - ############################################################ - if plug.PLUGINS_broken: - ttk.Separator(plugins_frame, orient=tk.HORIZONTAL).grid( - columnspan=3, padx=self.PADX, pady=self.SEPY, sticky=tk.EW, row=row.get() - ) - # LANG: Plugins - Label for list of 'broken' plugins that failed to load - nb.Label(plugins_frame, text=tr.tl('Broken Plugins')+':').grid( - padx=self.PADX, pady=self.PADY, sticky=tk.W, row=row.get() - ) - - for plugin in plug.PLUGINS_broken: - if plugin.folder: # 'system' ones have this set to None to suppress listing in Plugins prefs tab - nb.Label(plugins_frame, text=plugin.name).grid( - columnspan=2, padx=self.LISTX, pady=self.PADY, sticky=tk.W, row=row.get() - ) + self.sync_all_plugin_rows(plugins_frame) # LANG: Label on Settings > Plugins tab notebook.add(plugins_frame, text=tr.tl('Plugins')) # Tab heading in settings + def _cleanup_plugin_widgets(self, plugins_frame: tk.Frame): + # remove rows for plugins that were deleted or duplicated rows + configured = {} + for widget in list(plugins_frame.children.values()): + if hasattr(widget,"plugin_folder"): + folder = widget.plugin_folder + if not plug.get_plugin_by_folder(folder): + widget.grid_forget() + widget.destroy() + continue + #ui_row = widget.grid_info()["row"] + #if not configured.get(folder): + # configured[folder] = ui_row + # continue + #if configured.get(folder) == ui_row: + # continue + #widget.grid_forget() + #widget.destroy() + + def sync_all_plugin_rows(self, plugins_frame: tk.Frame): + self._cleanup_plugin_widgets(plugins_frame) + user_plugins = list(filter(lambda p: p.folder, plug.PLUGINS)) + for plugin in user_plugins: + found = False + for widget in plugins_frame.children.values(): + if isinstance(widget, nb.Checkbutton) and hasattr(widget, "plugin_folder"): + cb: nb.Checkbutton = widget + if cb.plugin_folder == plugin.folder: + found = True + self._update_plugin_state(plugin, cb) + break + if not found: + cb = nb.Checkbutton(plugins_frame) + cb.plugin_folder = plugin.folder + ui_row = plugins_frame.grid_size()[1] + cb.grid(column=0, row=ui_row, padx=self.PADX, pady=self.PADY, sticky=tk.W) + self._update_plugin_state(plugin, cb) + + def _update_plugin_state(self, plugin: plug.Plugin, cb: nb.Checkbutton): + ui_row = cb.grid_info()["row"] + if not plugin.name == plugin.folder and not cb.master.grid_slaves(ui_row, 1): + label = tk.Label(cb.master, text=plugin.folder+"/") + label.grid(column=1, row=ui_row, padx=self.PADX, pady=self.PADY, sticky=tk.W) + label.plugin_folder = plugin.folder + status = next(iter(cb.master.grid_slaves(ui_row, 2)),None) + if not status: + status = tk.Label(cb.master) + status.grid(column=2, row=ui_row, padx=self.PADX, pady=self.PADY, sticky=tk.W) + status.plugin_folder = plugin.folder + state = tk.NORMAL + if plugin.folder: + if not hasattr(cb, "cb_var"): + cb.cb_var = tk.IntVar() + cb.configure(variable=cb.cb_var) + cb.cb_var.set(1 if plugin.module else 0) + if plugin.folder.endswith('.disabled'): + state = tk.DISABLED + if plugin.broken: + state = tk.DISABLED + cb.configure(text=plugin.name, state=state, command=None) + status.configure(text=plugin.broken, fg="red") + elif state == tk.NORMAL and plugin.module: + cb.configure(text=plugin.name, state=state, command=lambda p=plugin,c=cb: self._disable_plugin(p,c)) + status.configure(text="running", fg="green") + else: + cb.configure(text=plugin.name, state=state, command=lambda p=plugin,c=cb: self._enable_plugin(p,c)) + status.configure(text="disabled", fg="gray") + + def _disable_plugin(self, plugin: plug.Plugin, cb: nb.Checkbutton|None, add_to_config:bool=True): + self.theApp.hide_plugin(plugin) + if plugin.module: + plugin = plug.disable_plugin(plugin, add_to_config) + if hasattr(plugin, "notebook_frame"): + self.notebook.forget(plugin.notebook_frame) + plugin.notebook_frame.destroy() + plugin.notebook_frame = None + if cb: + self._update_plugin_state(plugin, cb) + + def _enable_plugin(self, plugin: plug.Plugin, cb: nb.Checkbutton|None, add_to_config:bool=True): + plugin = plug.enable_plugin(plugin,add_to_config) + self.theApp.show_plugin(plugin) + plugin_frame = plugin.get_prefs(self.notebook, monitor.cmdr, monitor.is_beta) + if plugin_frame: + plugin.notebook_frame = plugin_frame + self.notebook.add(plugin_frame, text=plugin.name) + if cb: + self._update_plugin_state(plugin, cb) + + def cmdrchanged(self, event=None): """ Notify plugins of cmdr change. @@ -1087,6 +1116,58 @@ class PreferencesDialog(tk.Toplevel): pathvar.set(directory) self.outvarchanged() + def installbrowse(self, plugin_dir_path: str, plugins_frame: nb.Frame): + import tkinter.filedialog + initialdir = Path(plugin_dir_path).expanduser() + file = tkinter.filedialog.askopenfilename( + parent=self, + initialdir=initialdir, + filetypes = (("Zip files", "*.zip"), ("All files", "*.*")), + title=tr.tl("Select plugin ZIP file") + ) + user_plugins = list(filter(lambda p: p.folder, plug.PLUGINS)) + disabled_list = config.get_list('disabled_plugins', default=[]) + if file: + if not zipfile.is_zipfile(file): + messagebox.showwarning( + tr.tl('Error'), # LANG: Generic error prefix - following text is from Frontier auth service; + tr.tl('Invalid ZIP archive.'), # LANG: Invalid ZIP archive + parent=self.master + ) + return + name = os.path.splitext(os.path.basename(file))[0] + with zipfile.ZipFile(file, 'r') as myzip: + has_load = "load.py" in myzip.NameToInfo + has_dir_load = name+"/load.py" in myzip.NameToInfo + if not (has_load or has_dir_load): + messagebox.showwarning( + tr.tl('Error'), # LANG: Generic error prefix - following text is from Frontier auth service; + tr.tl('Not a valid plugin ZIP archive'), # LANG: Not a valid plugin ZIP archive + parent=self.master + ) + return + dest = os.path.join(initialdir, name) + old_plugin = plug.get_plugin_by_folder(name) + if old_plugin: + if not messagebox.askokcancel( + tr.tl('Error'), # LANG: Generic error prefix - following text is from Frontier auth service; + tr.tl('Plugin already installed. Upgrade?'), # LANG: Plugin already installed + parent=self.master + ): + return + self._disable_plugin(old_plugin, None, False) + plug.PLUGINS.remove(old_plugin) + myzip.extractall(path=initialdir if has_dir_load else os.path.join(initialdir, name)) + plug.sync_all_plugins() + for plugin in user_plugins: + if plugin.broken: + self._disable_plugin(plugin, None, False) + user_plugins = list(filter(lambda p: p.folder, plug.PLUGINS)) + for plugin in user_plugins: + if not plugin.broken and plugin.frame_id == 0 and disabled_list.count(plugin.folder) == 0: + self._enable_plugin(plugin, None, False) + self.sync_all_plugin_rows(plugins_frame) + def displaypath(self, pathvar: tk.StringVar, entryfield: tk.Entry) -> None: """ Display a path in a locked tk.Entry.