1
0
mirror of https://github.com/EDCD/EDMarketConnector.git synced 2025-06-15 06:42:15 +03:00

Support dynamic plugin install/enable/disable

This commit is contained in:
Maxim Kizub 2025-04-06 16:39:15 +03:00 committed by mkizub
parent 2c4e409ca3
commit f91454c56a
3 changed files with 327 additions and 188 deletions

View File

@ -441,6 +441,7 @@ class AppWindow:
self.minimizing = False self.minimizing = False
self.w.rowconfigure(0, weight=1) self.w.rowconfigure(0, weight=1)
self.w.columnconfigure(0, weight=1) self.w.columnconfigure(0, weight=1)
self.plugin_id = 0
# companion needs to be able to send <<CAPIResponse>> events # companion needs to be able to send <<CAPIResponse>> events
companion.session.set_tk_master(self.w) companion.session.set_tk_master(self.w)
@ -478,6 +479,7 @@ class AppWindow:
frame = tk.Frame(self.w, name=appname.lower()) frame = tk.Frame(self.w, name=appname.lower())
frame.grid(sticky=tk.NSEW) frame.grid(sticky=tk.NSEW)
frame.columnconfigure(1, weight=1) frame.columnconfigure(1, weight=1)
self.frame = frame
self.cmdr_label = tk.Label(frame, name='cmdr_label') self.cmdr_label = tk.Label(frame, name='cmdr_label')
self.cmdr = tk.Label(frame, compound=tk.RIGHT, anchor=tk.W, name='cmdr') 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) self.station.grid(row=ui_row, column=1, sticky=tk.EW)
ui_row += 1 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: for plugin in plug.PLUGINS:
# Per plugin separator self.show_plugin(plugin)
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()
# LANG: Update button in main window # LANG: Update button in main window
self.button = ttk.Button( 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 = 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=lambda: stats.StatsDialog(self.w, self.status))
self.file_menu.add_command(command=self.save_raw) 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_separator()
self.file_menu.add_command(command=self.onexit) self.file_menu.add_command(command=self.onexit)
self.menubar.add_cascade(menu=self.file_menu) self.menubar.add_cascade(menu=self.file_menu)
@ -753,6 +728,69 @@ class AppWindow:
else: else:
self.w.wm_iconify() 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: def update_suit_text(self) -> None:
"""Update the suit text for current type and loadout.""" """Update the suit text for current type and loadout."""
if not monitor.state['Odyssey']: if not monitor.state['Odyssey']:
@ -2261,7 +2299,7 @@ sys.path: {sys.path}'''
def messagebox_broken_plugins(): def messagebox_broken_plugins():
"""Display message about 'broken' plugins that failed to load.""" """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 # LANG: Popup-text about 'broken' plugins that failed to load
popup_text = tr.tl( popup_text = tr.tl(
"One or more of your enabled plugins failed to load. Please see the list on the '{PLUGINS}' " "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 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
ui_transparency = config.get_int('ui_transparency') ui_transparency = config.get_int('ui_transparency')
if ui_transparency == 0: if ui_transparency == 0:
@ -2328,8 +2334,6 @@ sys.path: {sys.path}'''
root.wm_attributes('-alpha', ui_transparency / 100) root.wm_attributes('-alpha', ui_transparency / 100)
# Display message box about plugins that failed to load # Display message box about plugins that failed to load
root.after(0, messagebox_broken_plugins) 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 # Show warning popup for killswitches matching current version
root.after(2, show_killswitch_poppup, root) root.after(2, show_killswitch_poppup, root)
# Start the main event loop # Start the main event loop

120
plug.py
View File

@ -28,8 +28,6 @@ logger = get_main_logger()
# List of loaded Plugins # List of loaded Plugins
PLUGINS = [] PLUGINS = []
PLUGINS_not_py3 = []
PLUGINS_broken = []
# For asynchronous error display # For asynchronous error display
@ -49,7 +47,7 @@ last_error = LastError()
class Plugin: class Plugin:
"""An EDMC 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. Load a single plugin.
@ -59,9 +57,11 @@ class Plugin:
:raises Exception: Typically, ImportError or OSError :raises Exception: Typically, ImportError or OSError
""" """
self.name: str = name # Display name. 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.module = None # None for disabled plugins.
self.logger: logging.Logger | None = plugin_logger 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: if loadfile:
logger.info(f'loading plugin "{name.replace(".", "_")}" from "{loadfile}"') logger.info(f'loading plugin "{name.replace(".", "_")}" from "{loadfile}"')
@ -78,14 +78,18 @@ class Plugin:
newname = module.plugin_start3(Path(loadfile).resolve().parent) newname = module.plugin_start3(Path(loadfile).resolve().parent)
self.name = str(newname) if newname else self.name self.name = str(newname) if newname else self.name
self.module = module self.module = module
self.broken = None
elif getattr(module, 'plugin_start', None): elif getattr(module, 'plugin_start', None):
self.broken = f'plugin {name} needs migrating'
logger.warning(f'plugin {name} needs migrating\n') logger.warning(f'plugin {name} needs migrating\n')
PLUGINS_not_py3.append(self)
else: else:
self.broken = 'needs python 3 migration'
logger.error(f'plugin {name} has no plugin_start3() function') logger.error(f'plugin {name} has no plugin_start3() function')
else: else:
self.broken = 'Failed to load from load.py'
logger.error(f'Failed to load Plugin "{name}" from file "{loadfile}"') 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}"') logger.exception(f': Failed for Plugin "{name}"')
raise raise
else: 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 # Add plugin folder to load path so packages can be loaded from plugin folder
sys.path.append(config.plugin_dir) sys.path.append(config.plugin_dir)
found = _load_found_plugins() sync_all_plugins()
PLUGINS.extend(sorted(found, key=lambda p: operator.attrgetter('name')(p).lower()))
def _load_internal_plugins(): def _load_internal_plugins():
@ -175,16 +178,42 @@ def _load_internal_plugins():
try: try:
plugin_name = name[:-3] plugin_name = name[:-3]
plugin_path = config.internal_plugin_dir_path / name plugin_path = config.internal_plugin_dir_path / name
plugin = Plugin(plugin_name, plugin_path, logger) plugin = Plugin(plugin_name, None, plugin_path, logger)
plugin.folder = None
internal.append(plugin) internal.append(plugin)
except Exception: except Exception:
logger.exception(f'Failure loading internal Plugin "{name}"') logger.exception(f'Failure loading internal Plugin "{name}"')
return internal return internal
def _load_found_plugins(): def load_plugin(folder: str):
found = [] 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* # 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. # 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 # 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())) not (p / '__init__.py').is_file(), p.name.lower()))
for plugin_file in plugin_files: for plugin_file in plugin_files:
name = plugin_file.name if not get_plugin_by_folder(plugin_file.name):
if not (config.plugin_dir_path / name).is_dir() or name.startswith(('.', '_')): load_plugin(plugin_file.name)
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
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]: def provides(fn_name: str) -> list[str]:
""" """
@ -381,7 +397,7 @@ def notify_journal_entry_cqc(
return error 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. Send a status entry to each plugin.
@ -468,3 +484,41 @@ def show_error(err: str) -> None:
if err and last_error.root: if err and last_error.root:
last_error.msg = str(err) last_error.msg = str(err)
last_error.root.event_generate('<<PluginError>>', when="tail") last_error.root.event_generate('<<PluginError>>', 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

257
prefs.py
View File

@ -4,6 +4,8 @@ from __future__ import annotations
import contextlib import contextlib
import logging import logging
import os.path
import zipfile
from os.path import expandvars, join, normpath from os.path import expandvars, join, normpath
from pathlib import Path from pathlib import Path
import subprocess import subprocess
@ -12,7 +14,7 @@ import tkinter as tk
import warnings import warnings
from os import system from os import system
from tkinter import colorchooser as tkColorChooser # type: ignore # noqa: N812 from tkinter import colorchooser as tkColorChooser # type: ignore # noqa: N812
from tkinter import ttk from tkinter import ttk, messagebox
from types import TracebackType from types import TracebackType
from typing import Any, Callable, Optional, Type from typing import Any, Callable, Optional, Type
import myNotebook as nb # noqa: N813 import myNotebook as nb # noqa: N813
@ -228,9 +230,10 @@ if sys.platform == 'win32':
class PreferencesDialog(tk.Toplevel): class PreferencesDialog(tk.Toplevel):
"""The EDMC preferences dialog.""" """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) super().__init__(parent)
self.theApp = app
self.parent = parent self.parent = parent
self.callback = callback self.callback = callback
self.req_restart = False self.req_restart = False
@ -265,6 +268,7 @@ class PreferencesDialog(tk.Toplevel):
notebook: nb.Notebook = nb.Notebook(frame) notebook: nb.Notebook = nb.Notebook(frame)
notebook.bind('<<NotebookTabChanged>>', self.tabchanged) # Recompute on tab change notebook.bind('<<NotebookTabChanged>>', self.tabchanged) # Recompute on tab change
self.notebook = notebook
self.PADX = 10 self.PADX = 10
self.BUTTONX = 12 # indent Checkbuttons and Radiobuttons self.BUTTONX = 12 # indent Checkbuttons and Radiobuttons
@ -409,6 +413,7 @@ class PreferencesDialog(tk.Toplevel):
for plugin in plug.PLUGINS: for plugin in plug.PLUGINS:
plugin_frame = plugin.get_prefs(notebook, monitor.cmdr, monitor.is_beta) plugin_frame = plugin.get_prefs(notebook, monitor.cmdr, monitor.is_beta)
if plugin_frame: if plugin_frame:
plugin.notebook_frame = plugin_frame
notebook.add(plugin_frame, text=plugin.name) notebook.add(plugin_frame, text=plugin.name)
def __setup_config_tab(self, notebook: ttk.Notebook) -> None: # noqa: CCR001 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 def __setup_plugin_tab(self, notebook: ttk.Notebook) -> None: # noqa: CCR001
# Plugin settings and info # Plugin settings and info
plugins_frame = nb.Frame(notebook) 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) row = AutoInc(start=0)
self.plugdir = tk.StringVar() self.plugdir = tk.StringVar()
self.plugdir.set(str(config.get_str('plugin_dir'))) 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 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()) self.plugdir_entry.grid(columnspan=4, padx=self.PADX, pady=self.BOXY, sticky=tk.EW, row=row.get())
with row as cur_row: with row as cur_row:
nb.Label( # Install Plugin Button
self.install_plugin_btn = ttk.Button(
plugins_frame, plugins_frame,
# Help text in settings # LANG: Label on button used to Install Plugin
# LANG: Tip/label about how to disable plugins text=tr.tl('Install Plugin'),
text=tr.tl( command=lambda frame=plugins_frame: self.installbrowse(config.plugin_dir_path, frame)
"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) self.install_plugin_btn.grid(column=0, padx=self.PADX, pady=self.PADY, sticky=tk.EW, row=cur_row)
# Open Plugin Folder Button # Open Plugin Folder Button
self.open_plug_folder_btn = ttk.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 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) ).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)) ttk.Separator(plugins_frame, orient=tk.HORIZONTAL).grid(
if enabled_plugins: columnspan=4, padx=self.PADX, pady=self.SEPY, sticky=tk.EW, 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,
nb.Label( # LANG: Label on list of enabled plugins
plugins_frame, text=tr.tl('Enabled Plugins')+':' # List of plugins in settings
# LANG: Label on list of enabled plugins ).grid(padx=self.PADX, pady=self.PADY, sticky=tk.W, row=row.get())
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: self.sync_all_plugin_rows(plugins_frame)
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()
)
# LANG: Label on Settings > Plugins tab # LANG: Label on Settings > Plugins tab
notebook.add(plugins_frame, text=tr.tl('Plugins')) # Tab heading in settings 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): def cmdrchanged(self, event=None):
""" """
Notify plugins of cmdr change. Notify plugins of cmdr change.
@ -1087,6 +1116,58 @@ class PreferencesDialog(tk.Toplevel):
pathvar.set(directory) pathvar.set(directory)
self.outvarchanged() 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: def displaypath(self, pathvar: tk.StringVar, entryfield: tk.Entry) -> None:
""" """
Display a path in a locked tk.Entry. Display a path in a locked tk.Entry.