Merge pull request #2582 from HullSeals/enhancement/plugin-browser

[#2572] Plugin Browser
This commit is contained in:
David Sangrey authored and GitHub committed 2026-01-02 22:08:49 -05:00
commit 708cd6eaff
9 files changed
+994 -142

No files matched your search

+2 -1
View File
@@ -33,6 +33,7 @@ EDMarketConnector.wxs
wix/components.wxs
edmarketconnector.xml
edmarketconnector-beta.xml
master_plugin_list.json
# Ignore Visual Elements Manifest file for Windows
EDMarketConnector.VisualElementsManifest.xml
@@ -59,4 +60,4 @@ pylint.txt
# Ignore Submodule data directory
coriolis-data/
FDevIDs/
FDevIDs/
+127 -68
View File
@@ -67,7 +67,6 @@ from config import appversion, appversion_nobuild, config, copyright
from EDMCLogging import edmclogger, logger, logging
from journal_lock import JournalLock, JournalLockResult
from update import check_for_fdev_updates, check_for_datafile_updates
from common_utils import log_locale, SERVER_RETRY
if __name__ == '__main__': # noqa: C901
@@ -445,6 +444,34 @@ from theme import theme
from ttkHyperlinkLabel import HyperlinkLabel, SHIPYARD_HTML_TEMPLATE
def _config_plugins(frame: tk.Frame, ui_row: int) -> tk.Frame:
for idx, plugin in enumerate(plug.PLUGINS, start=1):
plugin_frame = tk.Frame(frame, name=f"plugin_{idx}")
appitem = plugin.get_app(plugin_frame)
if not appitem:
plugin_frame.destroy()
continue
# Per plugin separator
plugin_sep = tk.Frame(frame, highlightthickness=1, name=f"plugin_hr_{idx}")
plugin_sep.grid(row=ui_row, columnspan=2, sticky=tk.EW)
ui_row += 1
# Plugin frame
plugin_frame.grid(row=ui_row, columnspan=2, sticky=tk.NSEW)
plugin_frame.columnconfigure(1, weight=1)
ui_row += 1
# Add plugin widgets
if isinstance(appitem, tuple) and len(appitem) == 2:
appitem[0].grid(row=ui_row - 1, column=0, sticky=tk.W)
appitem[1].grid(row=ui_row - 1, column=1, sticky=tk.EW)
else:
appitem.grid(row=ui_row - 1, column=0, columnspan=2, sticky=tk.EW)
return frame
class AppWindow:
"""Define the main application window."""
@@ -456,13 +483,13 @@ class AppWindow:
PADX = 5
def __init__(self, master: tk.Tk): # noqa: C901, CCR001 # TODO - can possibly factor something out
def __init__(self, master: tk.Tk): # noqa: CCR001 TODO - can possibly factor something out
self.early_journal_events: list[MutableMapping[str, Any]] = []
self.capi_query_holdoff_time = config.get_int('querytime', default=0) + companion.capi_query_cooldown
self.capi_fleetcarrier_query_holdoff_time = config.get_int('fleetcarrierquerytime', default=0) \
+ companion.capi_fleetcarrier_query_cooldown
query_time = config.get_int('querytime', default=0)
fleetcarrier_time = config.get_int('fleetcarrierquerytime', default=0)
self.capi_query_holdoff_time = query_time + companion.capi_query_cooldown
self.capi_fleetcarrier_query_holdoff_time = fleetcarrier_time + companion.capi_fleetcarrier_query_cooldown
self.w = master
self.w.title(applongname)
@@ -505,9 +532,9 @@ class AppWindow:
# TODO: Export to files and merge from them in future ?
self.theme_icon = tk.PhotoImage(
data='R0lGODlhFAAQAMZQAAoKCQoKCgsKCQwKCQsLCgwLCg4LCQ4LCg0MCg8MCRAMCRANChINCREOChIOChQPChgQChgRCxwTCyYVCSoXCS0YCTkdCTseCT0fCTsjDU0jB0EnDU8lB1ElB1MnCFIoCFMoCEkrDlkqCFwrCGEuCWIuCGQvCFs0D1w1D2wyCG0yCF82D182EHE0CHM0CHQ1CGQ5EHU2CHc3CHs4CH45CIA6CIE7CJdECIdLEolMEohQE5BQE41SFJBTE5lUE5pVE5RXFKNaFKVbFLVjFbZkFrxnFr9oFsNqFsVrF8RsFshtF89xF9NzGNh1GNl2GP+KG////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////yH5BAEKAH8ALAAAAAAUABAAAAeegAGCgiGDhoeIRDiIjIZGKzmNiAQBQxkRTU6am0tPCJSGShuSAUcLoIIbRYMFra4FAUgQAQCGJz6CDQ67vAFJJBi0hjBBD0w9PMnJOkAiJhaIKEI7HRoc19ceNAolwbWDLD8uAQnl5ga1I9CHEjEBAvDxAoMtFIYCBy+kFDKHAgM3ZtgYSLAGgwkp3pEyBOJCC2ELB31QATGioAoVAwEAOw==') # noqa: E501
self.theme_minimize = tk.BitmapImage(
self.img_theme_minimize = tk.BitmapImage(
data='#define im_width 16\n#define im_height 16\nstatic unsigned char im_bits[] = {\n 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x3f,\n 0xfc, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };\n') # noqa: E501
self.theme_close = tk.BitmapImage(
self.img_theme_close = tk.BitmapImage(
data='#define im_width 16\n#define im_height 16\nstatic unsigned char im_bits[] = {\n 0x00, 0x00, 0x00, 0x00, 0x0c, 0x30, 0x1c, 0x38, 0x38, 0x1c, 0x70, 0x0e,\n 0xe0, 0x07, 0xc0, 0x03, 0xc0, 0x03, 0xe0, 0x07, 0x70, 0x0e, 0x38, 0x1c,\n 0x1c, 0x38, 0x0c, 0x30, 0x00, 0x00, 0x00, 0x00 };\n') # noqa: E501
frame = tk.Frame(self.w, name=appname.lower())
@@ -532,58 +559,27 @@ class AppWindow:
ui_row = 1
self.cmdr_label.grid(row=ui_row, column=0, sticky=tk.W)
self.cmdr.grid(row=ui_row, column=1, sticky=tk.EW)
ui_row += 1
label_value_pairs = [
(self.cmdr_label, self.cmdr),
(self.ship_label, self.ship),
]
self.ship_label.grid(row=ui_row, column=0, sticky=tk.W)
self.ship.grid(row=ui_row, column=1, sticky=tk.EW)
ui_row += 1
self.suit_grid_row = ui_row
self.suit_grid_row = ui_row + len(label_value_pairs)
self.suit_shown = False
ui_row += 1
label_value_pairs.append((self.suit_label, self.suit))
self.system_label.grid(row=ui_row, column=0, sticky=tk.W)
self.system.grid(row=ui_row, column=1, sticky=tk.EW)
ui_row += 1
label_value_pairs.extend([
(self.system_label, self.system),
(self.station_label, self.station),
])
self.station_label.grid(row=ui_row, column=0, sticky=tk.W)
self.station.grid(row=ui_row, column=1, sticky=tk.EW)
ui_row += 1
for label, value in label_value_pairs:
label.grid(row=ui_row, column=0, sticky=tk.W)
value.grid(row=ui_row, column=1, sticky=tk.EW) # type: ignore
ui_row += 1
plugin_no = 0
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()
ui_row = frame.grid_size()[1] # start row
frame = _config_plugins(frame, ui_row)
# LANG: Update button in main window
self.button = ttk.Button(
@@ -614,8 +610,8 @@ class AppWindow:
self.status.grid(columnspan=2, sticky=tk.EW)
for child in frame.winfo_children():
child.grid_configure(padx=self.PADX, pady=( # type: ignore
sys.platform != 'win32' or isinstance(child, tk.Frame)) and 2 or 0)
pad_y = 2 if sys.platform != 'win32' or isinstance(child, tk.Frame) else 0
child.grid_configure(padx=self.PADX, pady=pad_y) # type: ignore
self.menubar = tk.Menu()
@@ -692,12 +688,12 @@ class AppWindow:
theme_titlebar.bind('<Button-1>', self.drag_start)
theme_titlebar.bind('<B1-Motion>', self.drag_continue)
theme_titlebar.bind('<ButtonRelease-1>', self.drag_end)
theme_minimize = tk.Label(self.theme_menubar, image=self.theme_minimize)
theme_minimize = tk.Label(self.theme_menubar, image=self.img_theme_minimize)
theme_minimize.grid(row=0, column=3, padx=2)
theme.button_bind(theme_minimize, self.oniconify, image=self.theme_minimize)
theme_close = tk.Label(self.theme_menubar, image=self.theme_close)
theme.button_bind(theme_minimize, self.oniconify, image=self.img_theme_minimize)
theme_close = tk.Label(self.theme_menubar, image=self.img_theme_close)
theme_close.grid(row=0, column=4, padx=2)
theme.button_bind(theme_close, self.onexit, image=self.theme_close)
theme.button_bind(theme_close, self.onexit, image=self.img_theme_close)
self.theme_file_menu = tk.Label(self.theme_menubar, anchor=tk.W)
self.theme_file_menu.grid(row=1, column=0, padx=self.PADX, sticky=tk.W)
theme.button_bind(self.theme_file_menu,
@@ -717,15 +713,15 @@ class AppWindow:
e.widget.winfo_rooty()
+ e.widget.winfo_height()))
tk.Frame(self.theme_menubar, highlightthickness=1).grid(columnspan=5, padx=self.PADX, sticky=tk.EW)
theme.register(self.theme_minimize) # images aren't automatically registered
theme.register(self.theme_close)
theme.register(self.img_theme_minimize) # images aren't automatically registered
theme.register(self.img_theme_close)
self.blank_menubar = tk.Frame(frame, name="blank_menubar")
tk.Label(self.blank_menubar).grid()
tk.Label(self.blank_menubar).grid()
tk.Frame(self.blank_menubar, height=2).grid()
theme.register_alternate((self.menubar, self.theme_menubar, self.blank_menubar),
{'row': 0, 'columnspan': 2, 'sticky': tk.NSEW})
self.w.resizable(tk.TRUE, tk.FALSE)
self.w.resizable(True, True)
# update geometry
if config.get_str('geometry'):
@@ -771,13 +767,76 @@ class AppWindow:
self.status['text'] = tr.tl("Awaiting Full CMDR Login") # LANG: Await Full CMDR Login to Game
# Start a protocol handler to handle cAPI registration. Requires main loop to be running.
self.w.after_idle(lambda: protocol.protocolhandler.start(self.w))
self.w.after_idle(lambda: self.postprefs(False)) # Companion login happens in callback from monitor
self.w.after_idle(self._after_idle_init)
self.toggle_suit_row(visible=False)
if args.start_min:
logger.warning("Trying to start minimized")
self.oniconify() if root.overrideredirect() else self.w.wm_iconify()
def _after_idle_init(self):
"""Combine after_idle tasks for startup to ensure only one queue."""
protocol.protocolhandler.start(self.w)
self.postprefs(False) # Companion login happens in callback from monitor
def _set_grabber(self, frame: tk.Frame | ttk.Frame) -> None:
if config.get_int("theme") != theme.THEME_DEFAULT:
grip_size = 14
self.resize_grip = tk.Canvas(
self.w,
width=grip_size,
height=grip_size,
highlightthickness=0,
bg=frame.cget("background"),
)
# draw diagonal grip lines
for i in range(4, grip_size, 4):
self.resize_grip.create_line(i, grip_size, grip_size, i, fill="#404040")
self.resize_grip.bind(
"<Enter>",
lambda e: self.resize_grip.configure(bg=frame.cget("background")),
)
self.resize_grip.bind(
"<Leave>",
lambda e: self.resize_grip.configure(bg=frame.cget("background")),
)
self.resize_grip.place(relx=1.0, rely=1.0, anchor="se")
def _resize_drag(event: tk.Event):
dx = event.x_root - self._resize_start_x
dy = event.y_root - self._resize_start_y
# calculate proposed size
new_w = self._resize_start_w + dx
new_h = self._resize_start_h + dy
# enforce minimum size
min_w, min_h = 50, 50
if new_w < min_w:
new_w = min_w
if new_h < min_h:
new_h = min_h
self.w.geometry(f"{new_w}x{new_h}")
def _resize_start(event: tk.Event):
self._resize_start_x = event.x_root
self._resize_start_y = event.y_root
self._resize_start_w = self.w.winfo_width()
self._resize_start_h = self.w.winfo_height()
self.resize_grip.bind("<Button-1>", _resize_start)
self.resize_grip.bind("<B1-Motion>", _resize_drag)
else:
try:
if self.resize_grip:
self.resize_grip.place_forget()
except AttributeError:
pass
def update_suit_text(self) -> None:
"""Update the suit text for current type and loadout."""
if not monitor.state['Odyssey']:
@@ -848,6 +907,8 @@ class AppWindow:
if dologin and monitor.cmdr:
self.login() # Login if not already logged in with this Cmdr
self._set_grabber(root.nametowidget(".edmarketconnector"))
if postargs.get('Update') and postargs.get('Track'):
track = postargs.get('Track')
# LANG: Inform the user the Update Track has changed
@@ -2382,8 +2443,6 @@ sys.path: {sys.path}'''
root.after(2, show_killswitch_poppup, root)
# Start the main event loop
try:
check_for_fdev_updates()
check_for_datafile_updates()
root.mainloop()
except KeyboardInterrupt:
logger.info("Ctrl+C Detected, Attempting Clean Shutdown")
+48
View File
@@ -845,3 +845,51 @@
/* common_coreutils.py: Unidentified colonisation ship; In files: common_coreutils.py:84:84 */
"System Colonisation Ship" = "System Colonisation Ship";
/* plugin_browser.py: Filter Plugin Categories */
"Filter by Category:" = "Filter by Category:";
/* plugin_browser.py: Plugin Browser Plug Name */
"Name" = "Name";
/* plugin_browser.py: Plugin Browser Plug Ver */
"Version" = "Version";
/* plugin_browser.py: Plugin Browser Last Update */
"Last Updated" = "Last Updated";
/* plugin_browser.py: Plugin Browser Category */
"Categories" = "Categories";
/* plugin_browser.py: Plugin Browser Description */
"Description" = "Description";
/* plugin_browser.py: Plugin Browser Stars */
"Stars" = "Stars";
/* plugin_browser.py: Plugin Browser Title */
"Plugin Browser" = "Plugin Browser";
/* plugin_browser.py: Open Plugin Repo */
"Open Plugin Repository" = "Open Plugin Repository";
/* plugin_browser.py: Report a Malfunctioning Plugin */
"Report Plugin" = "Report Plugin";
/* plugin_browser.py: Plugin Browser Authors */
"Authors" = "Authors";
/* plugin_browser.py: Plugin Browser License */
"License" = "License";
/* plugin_browser.py: Plugin Browser Reqs */
"Requirements" = "Requirements";
/* plugin_browser.py: Last Tested EDMC Version */
"Last Tested EDMC: {last_tested}" = "Last Tested EDMC: {last_tested}";
/* plugin_browser.py: Plugin Project Link */
"Main Project Link" = "Main Project Link";
/* plugin_browser.py: Plugin VT Link */
"VirusTotal Link" = "VirusTotal Link";
+10
View File
@@ -61,6 +61,16 @@ You should also keep an eye on [our GitHub Discussions](https://github.com/EDCD/
in case there are any proposed changes to EDMC plugin functionality. You can
do this by ensuring 'Discussions' is also ticked when following the steps
above to set up a 'Custom' watch on this repository.
---
## Plugin Versioning
We strongly encourage the use of [Semantic Version](https://semver.org/#semantic-versioning-specification-semver)
for plugins. This should be done by establishing a `VERSION` constant or `__version__` dunder as a string with the value `Major.Minor.Patch`.
This version is used by the EDMC Plugin Browser to compare versions of plugins and allow plugins to use upcoming
planned features such as the Plugin Auto-Updater. While this is not a required part of the Plugin spec, it is highly
recommended and may become required at a later date.
---
+9
View File
@@ -177,6 +177,15 @@ class Plugin:
logger.exception(f'Failed for Plugin "{self.name}"')
return None
def get_version(self) -> str | None:
"""
If the plugin provides a VERSION identifier, return it.
:returns: String with the VERSION information or None.
"""
has_version = self._get_func('VERSION') or self._get_func('__version__')
return has_version if has_version else None
def load_plugins(master: tk.Tk) -> None:
"""Find and load all plugins."""
+618
View File
@@ -0,0 +1,618 @@
"""
plugin_browser.py - EDMC's Official Plugin Browser.
Copyright (c) EDCD, All Rights Reserved
Licensed under the GNU General Public License v2 or later.
See LICENSE file.
"""
from __future__ import annotations
import os
import json
import sys
import webbrowser
import tkinter as tk
from tkinter import ttk
from PIL import Image, ImageTk
from typing import Callable, cast
import requests
from io import BytesIO
from datetime import datetime
import myNotebook as nb # noqa: N813
import plug
from config import appversion_nobuild
from EDMCLogging import get_main_logger
from l10n import translations as tr
from ttkHyperlinkLabel import HyperlinkLabel
import semantic_version
logger = get_main_logger()
# TODO in 6.2 or later: Install/Uninstall Plugin, notify user a plugin update is available.
def get_plugins():
"""DEV: Print Installed Non-Core Plugins."""
print("Installed Plugins")
for plugin in plug.PLUGINS:
if plugin.folder: # Exclude Default Plugins
print(vars(plugin))
print(plugin.get_version())
def read_plugin_list():
"""Read the Plugin List distributed by EDMC."""
with open("master_plugin_list.json", encoding="utf-8") as plugin_list_file:
plugin_list = json.load(plugin_list_file)
plugins_by_id = {p["pluginName"]: p for p in plugin_list}
return plugin_list, plugins_by_id
def open_plugin_main(plugin) -> None:
"""Open the Plugin's Main Repository."""
webbrowser.open(plugin.get("pluginMainLink"))
def report_plugin(plugin) -> None: # FIXME: Update to live repo.
"""Open the Webbrowser to the new plugin issue report."""
webbrowser.open(
f"https://github.com/Rixxan/EDMC_Plugin_Registry_Dev/issues/new?"
f"template=report_plugin.md&title=Plugin Report: "
f"{plugin.get('pluginName')} ({plugin.get('pluginHash')[:8]})"
)
class PluginBrowserMixIn:
"""Separated Class for the Plugin Browser."""
def __init__(self):
"""Initialize the Plugin Browser."""
self.PADX = 10
self.BUTTONX = 12 # indent Checkbuttons and Radiobuttons
self.LISTX = 25 # indent listed items
self.PADY = 1 # close spacing
self.BOXY = 2 # box spacing
self.SEPY = 10 # separator line spacing
# Setup Plugin Browser Options
self.browser_plugins, self.browser_plugins_by_id = read_plugin_list()
self._plugin_sort_reverse = {
"pluginName": False,
"pluginLastUpdate": False,
"pluginStars": False,
}
self.selected_plugin: dict | None = None
self._plugin_last_tested_label: nb.Label | None = None
# def dev_install_plugin(self, plugin) -> None:
# """TEMP."""
# print("Install", plugin.get("pluginName"))
#
# def dev_uninstall_plugin(self, plugin) -> None:
# """TEMP."""
# print("Uninstall", plugin.get("pluginName"))
#
# def dev_update_plugin(self, plugin) -> None:
# """TEMP."""
# print("Update", plugin.get("pluginName"))
def setup_browser_tab(self, notebook: nb.Notebook, row) -> None:
"""Set up the Plugin Browser tab in Preferences."""
if not hasattr(self, "browser_plugins"):
self.browser_plugins, self.browser_plugins_by_id = read_plugin_list()
self._plugin_sort_reverse = {"pluginName": False, "pluginLastUpdate": False}
self.selected_plugin = None
categories = sorted(
{
cat
for plugin in self.browser_plugins
for cat in plugin.get("pluginCategory", [])
}
)
categories.insert(0, "All")
plugins_frame = nb.Frame(notebook)
HyperlinkLabel(
plugins_frame,
text="EDMC Plugin Browser",
background=nb.Label().cget("background"),
url="https://github.com/Rixxan/EDMC_Plugin_Registry_Dev", # Fixme
underline=True,
).grid(row=row.get(), padx=self.PADX, pady=(self.PADY, 1), sticky=tk.W)
# Add after the "Available Plugins" label
categories = sorted(
{
cat
for plugin in self.browser_plugins
for cat in plugin.get("pluginCategory", [])
}
)
categories.insert(0, "All") # default to show all
self.selected_category = tk.StringVar(value="All")
# Place the dropdown on its own row (separate from the tree)
# LANG: Filter Plugin Categories
category_label = nb.Label(plugins_frame, text=tr.tl("Filter by Category:"))
category_label.grid(
row=row.get(), column=0, sticky=tk.W, padx=self.PADX, pady=(0, self.PADY)
)
category_dropdown = nb.OptionMenu(
plugins_frame,
self.selected_category,
"All",
*categories,
command=self.__on_category_selected, # callback
)
category_dropdown.config(width=25) # make the dropdown wider
category_dropdown.grid(
row=row.get(), column=0, sticky=tk.W, padx=self.PADX, pady=(0, self.PADY)
)
ttk.Separator(plugins_frame, orient=tk.HORIZONTAL).grid(
columnspan=4, padx=self.PADX, pady=self.SEPY, sticky=tk.EW, row=row.get()
)
tree_container = nb.Frame(plugins_frame) # type: ignore
tree_row = row.get()
tree_container.grid(row=tree_row, column=0, sticky=tk.NSEW, padx=self.PADX)
plugins_frame.columnconfigure(0, weight=1)
columns = ("name", "version", "updated", "category", "description", "stars")
self.plugins_tree = ttk.Treeview(
tree_container,
columns=columns,
show="headings",
selectmode="browse",
height=12,
)
self.plugins_tree.heading(
"name",
# LANG: Plugin Browser Plug Name
text=tr.tl("Name"),
command=lambda: self.__sort_plugins("pluginName"),
)
# LANG: Plugin Browser Plug Ver
self.plugins_tree.heading("version", text=tr.tl("Version"))
self.plugins_tree.heading(
"updated",
text=tr.tl("Last Updated"), # LANG: Plugin Browser Last Update
command=lambda: self.__sort_plugins("pluginLastUpdate"),
)
self.plugins_tree.heading(
"category",
text=tr.tl("Categories"), # LANG: Plugin Browser Category
)
self.plugins_tree.heading(
"description",
text=tr.tl("Description"), # LANG: Plugin Browser Description
)
self.plugins_tree.heading(
"stars",
# LANG: Plugin Browser Stars
text=tr.tl("Stars"),
command=lambda: self.__sort_plugins("pluginStars"),
)
self.plugins_tree.column("name", width=40)
self.plugins_tree.column("version", width=40, anchor=tk.CENTER)
self.plugins_tree.column("updated", width=40, anchor=tk.CENTER)
self.plugins_tree.column("category", width=120, anchor=tk.CENTER)
self.plugins_tree.column("description", width=240)
self.plugins_tree.column("stars", width=40)
scrollbar = ttk.Scrollbar(tree_container, command=self.plugins_tree.yview)
self.plugins_tree.configure(yscrollcommand=scrollbar.set)
self.plugins_tree.grid(row=0, column=0, sticky=tk.NSEW)
scrollbar.grid(row=0, column=1, sticky=tk.NS)
tree_container.columnconfigure(0, weight=1)
tree_container.rowconfigure(0, weight=0)
ttk.Separator(plugins_frame, orient=tk.HORIZONTAL).grid(
columnspan=4, padx=self.PADX, pady=self.SEPY, sticky=tk.EW, row=row.get()
)
self.plugin_details_frame = nb.Frame(plugins_frame) # type: ignore
details_row = row.get()
self.plugin_details_frame.grid(
row=details_row, column=0, sticky=tk.NW, padx=self.PADX
)
self.plugin_details_frame.columnconfigure(1, weight=1)
self.plugin_details_frame.grid_propagate(True)
self._plugin_icon_cache: dict[str, ImageTk.PhotoImage] = {}
self._plugin_icon_label = nb.Label(self.plugin_details_frame)
self._plugin_icon_label.grid(
row=0, column=0, rowspan=3, sticky=tk.NW, padx=(0, 12)
)
self._plugin_hyperlinks: list[HyperlinkLabel] = []
self._plugin_title = nb.Label(
self.plugin_details_frame,
font=("TkDefaultFont", 10, "bold"),
wraplength=600,
justify=tk.LEFT,
)
self._plugin_title.grid(row=0, column=1, sticky=tk.W)
self._plugin_meta = nb.Label(
self.plugin_details_frame,
justify=tk.LEFT,
)
self._plugin_meta.grid(row=1, column=1, sticky=tk.W, pady=(1, 2))
self._plugin_description = nb.Label(
self.plugin_details_frame,
wraplength=600,
justify=tk.LEFT,
)
self._plugin_description.grid(row=2, column=1, sticky=tk.W)
self._create_plugin_action_buttons()
self.plugins_tree.bind("<<TreeviewSelect>>", self.__on_plugin_selected)
self.__populate_plugins_tree()
notebook.add(
# LANG: Plugin Browser Title
plugins_frame,
text=tr.tl("Plugin Browser"),
)
def __get_filtered_plugins(self) -> list[dict]:
category = self.selected_category.get()
if category == "All":
return self.browser_plugins
return [
p for p in self.browser_plugins if category in p.get("pluginCategory", [])
]
def _run_plugin_action(self, handler) -> None:
if self.selected_plugin is None:
return
handler(self.selected_plugin)
def _create_plugin_action_buttons(self) -> None:
self.plugin_actions_frame = nb.Frame(self.plugin_details_frame) # type: ignore
self.plugin_actions_frame.grid(
row=0, column=2, rowspan=6, sticky=tk.N, padx=(12, 0)
)
self._plugin_action_buttons: list[nb.Button] = []
actions = [
(
# LANG: Open Plugin Repo
tr.tl("Open Plugin Repository"),
open_plugin_main,
),
# ("Install", self.dev_install_plugin), # Upcoming Feature
# ("Uninstall", self.dev_uninstall_plugin), # Upcoming Feature
# ("Update", self.dev_update_plugin), # Upcoming Feature
(
# LANG: Report a Malfunctioning Plugin
tr.tl("Report Plugin"),
report_plugin,
),
]
for idx, (label, handler) in enumerate(actions):
btn = nb.Button(
self.plugin_actions_frame,
text=label,
command=cast(
Callable[[], None], lambda h=handler: self._run_plugin_action(h)
),
)
btn.grid(
row=idx,
column=0,
padx=self.LISTX,
pady=(0 if idx == 0 else self.PADY),
sticky=tk.EW,
)
btn.state(["disabled"])
self._plugin_action_buttons.append(btn)
self.plugin_actions_frame.columnconfigure(0, weight=1)
self.plugin_actions_frame.grid_remove()
def __populate_plugins_tree(self, plugins: list[dict] | None = None) -> None:
self.plugins_tree.delete(*self.plugins_tree.get_children())
if plugins is None:
plugins = self.__get_filtered_plugins()
# Rebuild Treeview ID to plugin mapping
self.browser_plugins_by_id = {}
for index, plugin in enumerate(plugins):
plugin_id = f"plugin_{index}"
self.browser_plugins_by_id[plugin_id] = plugin
self.plugins_tree.insert(
"",
tk.END,
iid=plugin_id,
values=(
plugin.get("pluginName", ""),
plugin.get("pluginVer", ""),
plugin.get("pluginLastUpdate", ""),
", ".join(plugin.get("pluginCategory", [])),
plugin.get("pluginDesc", ""),
plugin.get("pluginStars", ""),
),
)
self._set_plugin_action_visibility(bool(plugins))
self.plugins_tree.selection_remove(self.plugins_tree.selection())
self.selected_plugin = None
self.__clear_plugin_details()
def __on_category_selected(self, _event=None):
# Simply repopulate Treeview with filtered plugins
self.__populate_plugins_tree()
def __sort_plugins(self, key: str) -> None:
# Initialize sort state if missing
if key not in self._plugin_sort_reverse:
self._plugin_sort_reverse[key] = False
reverse = self._plugin_sort_reverse[key]
self._plugin_sort_reverse[key] = not reverse
self._current_sort_key = key
if key == "pluginLastUpdate":
self.browser_plugins.sort(
key=lambda p: datetime.fromisoformat(
p.get("pluginLastUpdate") or "1900-01-01"
),
reverse=reverse,
)
elif key == "pluginStars":
self.browser_plugins.sort(
key=lambda p: int(p.get("pluginStars") or 0),
reverse=reverse,
)
else:
self.browser_plugins.sort(
key=lambda p: (p.get(key) or "").casefold(),
reverse=reverse,
)
# Repopulate with current category filter
self.__populate_plugins_tree()
def __on_plugin_selected(self, event) -> None:
self._clear_plugin_links()
selection = self.plugins_tree.selection()
if not selection:
self._clear_selection_state()
return
plugin_id = selection[0]
plugin = self.browser_plugins_by_id.get(plugin_id)
if not plugin:
self._clear_selection_state()
return
self.selected_plugin = plugin
self._set_plugin_action_state(True)
self._plugin_title.configure(text=plugin.get("pluginName", ""))
meta_lines = self._build_plugin_meta(plugin)
self._plugin_meta.configure(text="\n".join(meta_lines))
link_row = self._update_last_tested(plugin)
self._build_plugin_links(plugin, link_row)
self._plugin_description.configure(text=plugin.get("pluginDesc", ""))
self._update_plugin_icon(plugin_id, plugin)
self._set_plugin_action_visibility(bool(self.browser_plugins))
def _clear_selection_state(self) -> None:
self.selected_plugin = None
self.__clear_plugin_details()
def _build_plugin_meta(self, plugin: dict) -> list[str]:
fields = [
(tr.tl("Version"), "pluginVer"), # LANG: Plugin Browser Plug Ver
(
# LANG: Plugin Browser Last Update
tr.tl("Last Updated"),
"pluginLastUpdate",
),
(
# LANG: Plugin Browser Category
tr.tl("Categories"),
"pluginCategory",
lambda v: ", ".join(v),
),
(
# LANG: Plugin Browser Authors
tr.tl("Authors"),
"pluginAuthors",
lambda v: ", ".join(v),
),
(tr.tl("License"), "pluginLicense"), # LANG: Plugin Browser License
(tr.tl("Stars"), "pluginStars"), # LANG: Plugin Browser Stars
(
# LANG: Plugin Browser Reqs
tr.tl("Requirements"),
"pluginRequirements",
lambda v: ", ".join(v),
),
]
meta = []
for label, key, *formatter in fields:
value = plugin.get(key)
if value:
if formatter:
value = formatter[0](value) # type: ignore
meta.append(f"{label}: {value}")
return meta
def _update_last_tested(self, plugin: dict) -> int:
last_tested = plugin.get("pluginLastTestedEDMC")
if not last_tested:
if getattr(self, "_plugin_last_tested_label", None):
self._plugin_last_tested_label.grid_forget() # type: ignore
self._plugin_last_tested_label = None
return 3
tested = semantic_version.Version.coerce(last_tested)
current = appversion_nobuild()
major_diff = tested.major - current.major
minor_diff = tested.minor - current.minor
if major_diff == 0 and abs(minor_diff) <= 1:
color = "green"
elif major_diff == 0 and abs(minor_diff) <= 2:
color = "dark goldenrod"
else:
color = "red"
# LANG: Last Tested EDMC Version
text = tr.tl("Last Tested EDMC: {last_tested}").format(last_tested=last_tested)
if getattr(self, "_plugin_last_tested_label", None):
self._plugin_last_tested_label.configure(text=text, foreground=color) # type: ignore
else:
self._plugin_last_tested_label = nb.Label(
self.plugin_details_frame,
text=text,
foreground=color,
justify=tk.LEFT,
)
self._plugin_last_tested_label.grid(row=3, column=1, sticky=tk.W)
return 4
def _clear_plugin_links(self) -> None:
for hl in self._plugin_hyperlinks:
hl.destroy()
self._plugin_hyperlinks.clear()
def _build_plugin_links(self, plugin: dict, start_row: int) -> None:
links = [
(
# LANG: Plugin Project Link
tr.tl("Main Project Link"),
plugin.get("pluginMainLink"),
),
(tr.tl("VirusTotal Link"), plugin.get("pluginVT")), # LANG: Plugin VT Link
]
bg = nb.Label().cget("background")
row = start_row
for text, url in links:
if not url:
continue
hl = HyperlinkLabel(
self.plugin_details_frame,
text=text,
background=bg,
url=url,
underline=True,
)
hl.grid(row=row, column=1, padx=self.PADX, pady=self.PADY, sticky=tk.W)
self._plugin_hyperlinks.append(hl)
row += 1
def _update_plugin_icon(self, plugin_id: str, plugin: dict) -> None:
icon_url = plugin.get("pluginIcon")
icon_key = plugin_id if icon_url else "__fallback__"
url = icon_url or os.path.join(
os.path.dirname(sys.argv[0]),
"io.edcd.EDMarketConnector.png",
)
self.__load_plugin_icon(icon_key, url)
def __clear_plugin_details(self) -> None:
self._plugin_title.configure(text="")
self._plugin_meta.configure(text="")
self._plugin_description.configure(text="")
# Last Tested
if getattr(self, "_plugin_last_tested_label", None) is not None:
self._plugin_last_tested_label.grid_forget() # type: ignore
self._plugin_last_tested_label = None
# Plugin Icon
self._plugin_icon_label.configure(image="")
self._plugin_icon_label.image = None # type: ignore
# Hyperlinks
if hasattr(self, "_plugin_hyperlinks"):
for hl in self._plugin_hyperlinks:
hl.destroy()
self._plugin_hyperlinks = []
self._set_plugin_action_state(False)
self._set_plugin_action_visibility(False)
def __load_plugin_icon(self, plugin_id: str, url: str) -> None:
if plugin_id in self._plugin_icon_cache:
tk_image = self._plugin_icon_cache[plugin_id]
self._plugin_icon_label.configure(image=tk_image)
self._plugin_icon_label.image = tk_image # type: ignore
return
try:
# Open PIL image
pil_image = (
Image.open(url).convert("RGBA")
if os.path.isfile(url)
else Image.open(BytesIO(requests.get(url, timeout=5).content)).convert(
"RGBA"
)
)
# Resize while maintaining aspect ratio
pil_image.thumbnail((64, 64), Image.Resampling.LANCZOS)
# Paste onto fixed 64x64 transparent canvas
canvas = Image.new("RGBA", (64, 64), (0, 0, 0, 0))
offset = ((64 - pil_image.width) // 2, (64 - pil_image.height) // 2)
canvas.paste(pil_image, offset)
# Convert to Tkinter PhotoImage
tk_image = ImageTk.PhotoImage(canvas, master=self.plugins_tree)
self._plugin_icon_cache[plugin_id] = tk_image
self._plugin_icon_label.configure(image=tk_image)
self._plugin_icon_label.image = tk_image # type: ignore
except Exception:
self._plugin_icon_label.configure(image="")
self._plugin_icon_label.image = None # type: ignore
def _set_plugin_action_visibility(self, visible: bool) -> None:
if not hasattr(self, "plugin_actions_frame"):
return
if visible:
self.plugin_actions_frame.grid()
else:
self.plugin_actions_frame.grid_remove()
def _set_plugin_action_state(self, enabled: bool) -> None:
for btn in self._plugin_action_buttons:
if enabled:
btn.state(["!disabled"])
else:
btn.state(["disabled"])
+8 -4
View File
@@ -15,6 +15,7 @@ from tkinter import ttk
from types import TracebackType
from typing import Any
from collections.abc import Callable
import plugin_browser
import myNotebook as nb # noqa: N813
import plug
from config import appversion_nobuild, config
@@ -238,7 +239,7 @@ if sys.platform == 'win32':
return '\\'.join(display_components)
class PreferencesDialog(tk.Toplevel):
class PreferencesDialog(tk.Toplevel, plugin_browser.PluginBrowserMixIn):
"""The EDMC preferences dialog."""
def __init__(self, parent: tk.Tk, callback: Callable | None):
@@ -291,6 +292,7 @@ class PreferencesDialog(tk.Toplevel):
self.__setup_output_tab(notebook)
self.__setup_privacy_tab(notebook)
self.__setup_plugin_tab(notebook)
self.setup_browser_tab(notebook, AutoInc())
self.__setup_plugin_tabs(notebook)
# Set up the button frame
@@ -981,9 +983,11 @@ class PreferencesDialog(tk.Toplevel):
for plugin in enabled_plugins:
curr_row = row.get()
label = nb.Label(plugins_frame,
text=plugin.name if plugin.name == plugin.folder
else f'{plugin.folder} ({plugin.name})')
plugin_name = plugin.name if plugin.name == plugin.folder else f'{plugin.folder} ({plugin.name})'
plugver = plugin.get_version()
if plugver:
plugin_name += f" v. {plugver}"
label = nb.Label(plugins_frame, text=plugin_name)
label.grid(column=0, columnspan=2, padx=self.LISTX, pady=self.PADY, sticky=tk.W, row=curr_row)
+51 -28
View File
@@ -220,38 +220,61 @@ class _Theme:
widget.bind('<Enter>', lambda e: self._enter(e, image))
widget.bind('<Leave>', lambda e: self._leave(e, image))
def _enter(self, event: tk.Event, image: tk.BitmapImage | None) -> None:
widget = event.widget
if widget and widget['state'] != tk.DISABLED:
try:
widget.configure(state=tk.ACTIVE) # type: ignore
def _enter(self, event, widget=None, image=None): # noqa: CCR001
"""Handle mouse entering a widget or image."""
if widget is not None:
# Only apply foreground/background if the widget supports them
config_keys = widget.keys() if hasattr(widget, 'keys') else []
kwargs = {}
if 'foreground' in config_keys:
kwargs['foreground'] = self.current.get('highlightforeground', '')
if 'background' in config_keys:
kwargs['background'] = self.current.get('highlightbackground', '')
if kwargs:
widget.configure(**kwargs)
except Exception:
logger.exception(f'Failure setting widget active: {widget=}')
if image:
if image is not None:
# PhotoImage cannot be configured with fg/bg, skip safely
if not isinstance(image, tk.PhotoImage):
try:
image.configure(foreground=self.current['activeforeground'],
background=self.current['activebackground'])
config_keys = image.keys() if hasattr(image, 'keys') else []
kwargs = {}
if 'foreground' in config_keys:
kwargs['foreground'] = self.current.get('highlightforeground', '')
if 'background' in config_keys:
kwargs['background'] = self.current.get('highlightbackground', '')
if kwargs:
image.configure(**kwargs)
except tk.TclError:
# silently ignore unsupported image types
pass
except Exception:
logger.exception(f'Failure configuring image: {image=}')
def _leave(self, event, widget=None, image=None): # noqa: CCR001
"""Handle mouse leaving a widget or image."""
if widget is not None:
config_keys = widget.keys() if hasattr(widget, 'keys') else []
kwargs = {}
if 'foreground' in config_keys:
kwargs['foreground'] = self.current.get('foreground', '')
if 'background' in config_keys:
kwargs['background'] = self.current.get('background', '')
if kwargs:
widget.configure(**kwargs)
def _leave(self, event: tk.Event, image: tk.BitmapImage | None) -> None:
widget = event.widget
if widget and widget['state'] != tk.DISABLED:
try:
widget.configure(state=tk.NORMAL) # type: ignore
except Exception:
logger.exception(f'Failure setting widget normal: {widget=}')
if image:
if image is not None:
# Skip PhotoImage objects; safely handle other types
if not isinstance(image, tk.PhotoImage):
try:
image.configure(foreground=self.current['foreground'], background=self.current['background'])
except Exception:
logger.exception(f'Failure configuring image: {image=}')
config_keys = image.keys() if hasattr(image, 'keys') else []
kwargs = {}
if 'foreground' in config_keys:
kwargs['foreground'] = self.current.get('foreground', '')
if 'background' in config_keys:
kwargs['background'] = self.current.get('background', '')
if kwargs:
image.configure(**kwargs)
except tk.TclError:
pass
# Set up colors
def _colors(self, root: tk.Tk, theme: int) -> None:
@@ -407,7 +430,7 @@ class _Theme:
# Switch menus
for pair, gridopts in self.widgets_pair:
for widget in pair:
if isinstance(widget, tk.Widget):
if isinstance(widget, tk.Widget) and not widget.winfo_exists():
widget.grid_remove()
if isinstance(pair[0], tk.Menu):
+121 -41
View File
@@ -8,10 +8,13 @@ See LICENSE file.
from __future__ import annotations
import pathlib
import hashlib
import shutil
import sys
import threading
import os
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from tkinter import messagebox
from traceback import print_exc
@@ -28,35 +31,128 @@ if TYPE_CHECKING:
logger = get_main_logger()
HTTP_TIMEOUT = (5, 20) # (connect, read)
HTTP_RETRIES = 3
RETRY_BACKOFF = 1.5 # seconds multiplier
MAX_WORKERS = 8
def read_normalized_file(path: pathlib.Path) -> str:
def read_normalized_file(path: pathlib.Path) -> tuple[str, str, str] | None:
"""Read a UTF-8 (with BOM-safe) file and normalize line endings and whitespace."""
try:
return path.read_bytes().decode('utf-8-sig').replace('\r\n', '\n').strip()
raw = path.read_bytes()
except FileNotFoundError:
return ''
return None
newline = '\r\n' if b'\r\n' in raw else '\n'
text = raw.decode('utf-8-sig')
normalized = text.replace('\r\n', '\n').strip()
return normalized, hashlib.sha256(normalized.encode()).hexdigest(), newline
def copy_bundle_file(filename: str, dest_dir: pathlib.Path) -> str:
"""Attempt to copy a missing file from the bundled FDevIDs folder."""
def copy_bundle_file(filename: str, dest_dir: pathlib.Path) -> tuple[str, str] | None:
"""
Attempt to copy a missing file from the bundled FDevIDs folder.
Returns (content, hash) or None.
"""
try:
shutil.copy(f"FDevIDs/{filename}", dest_dir)
return read_normalized_file(dest_dir / filename)
dest = dest_dir / filename
shutil.copy(f"FDevIDs/{filename}", dest)
return read_normalized_file(dest) # type: ignore
except (FileNotFoundError, shutil.SameFileError):
logger.info("Bundle copy failed or identical; continuing with empty content.")
return ''
def fetch_remote_file(url: str) -> str | None:
"""Fetch a remote file and normalize its content."""
try:
response = requests.get(url, timeout=20)
response.raise_for_status()
return response.text.replace('\r\n', '\n').strip()
except requests.RequestException:
return None
def fetch_remote_file(url: str) -> tuple[str, str] | None:
"""
Fetch a remote file with retries.
Returns (normalized_text, hash) or None on failure.
"""
delay = 1.0
for attempt in range(1, HTTP_RETRIES + 1):
try:
response = requests.get(url, timeout=HTTP_TIMEOUT)
response.raise_for_status()
text = response.text.replace('\r\n', '\n').strip()
return text, hashlib.sha256(text.encode()).hexdigest()
except requests.RequestException as exc:
if attempt >= HTTP_RETRIES:
logger.debug("Download failed after %d attempts: %s", attempt, exc)
return None
time.sleep(delay)
delay *= RETRY_BACKOFF
return None
def update_single_file(
directory: pathlib.Path,
filename: str,
url: str,
silent: bool = False,
) -> None:
"""Update a single bundle file."""
file_path = directory / filename
local = read_normalized_file(file_path)
if local is None:
local = copy_bundle_file(filename, directory) # type: ignore
if local:
local_text, local_hash, newline = local
else:
local_text, local_hash, newline = '', '', '\n' # noqa: F841
remote = fetch_remote_file(url)
if not remote:
if not silent:
logger.error(f'Failed to download {filename}! Unable to continue.')
return
remote_text, remote_hash = remote
if local_hash == remote_hash:
if not silent:
logger.info(f'{filename} already up to date.')
return
if not silent:
logger.info(f'Updating file {filename}...')
# Restore original newline style
output = remote_text.replace('\n', newline)
file_path.write_text(output, encoding='utf-8', newline='')
def update_files(
directory: pathlib.Path,
files_urls: dict[str, str],
silent: bool = False,
) -> None:
"""Start threads to update bundle files."""
directory.mkdir(parents=True, exist_ok=True)
max_workers = min(MAX_WORKERS, len(files_urls))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = [
executor.submit(update_single_file, directory, filename, url, silent)
for filename, url in files_urls.items()
]
for future in as_completed(futures):
try:
future.result()
except Exception:
logger.exception("Unexpected error while updating files")
def check_for_fdev_updates(silent: bool = False, local: bool = False) -> None:
"""Check for and download FDEV ID file updates."""
base_path = config.respath_path if local else config.app_dir_path
@@ -67,7 +163,8 @@ def check_for_fdev_updates(silent: bool = False, local: bool = False) -> None:
'commodity.csv': 'https://raw.githubusercontent.com/EDCD/FDevIDs/master/commodity.csv',
'rare_commodity.csv': 'https://raw.githubusercontent.com/EDCD/FDevIDs/master/rare_commodity.csv'
}
if not silent:
logger.info(f"Checking for {'local ' if local else ''}FDEVID file updates...")
update_files(fdevid_dir, files_urls, silent)
@@ -76,30 +173,13 @@ def check_for_datafile_updates(silent: bool = False, local: bool = False) -> Non
base_path = config.respath_path if local else config.app_dir_path
files_urls = {
'modules.json': 'https://raw.githubusercontent.com/EDCD/EDMarketConnector/refs/heads/releases/modules.json',
'ships.json': 'https://raw.githubusercontent.com/EDCD/EDMarketConnector/refs/heads/releases/ships.json'
'ships.json': 'https://raw.githubusercontent.com/EDCD/EDMarketConnector/refs/heads/releases/ships.json',
'master_plugin_list.json': 'https://raw.githubusercontent.com/Rixxan/'
'EDMC_Plugin_Registry_Dev/refs/heads/master/master_plugin_list.json'
}
update_files(base_path, files_urls, silent)
def update_files(directory: pathlib.Path, files_urls: dict[str, str], silent: bool = False) -> None:
"""Update the provided files from a remote source."""
for filename, url in files_urls.items():
file_path = directory / filename
local_content = read_normalized_file(file_path) or copy_bundle_file(filename, directory)
remote_content = fetch_remote_file(url)
if not remote_content:
if not silent:
logger.error(f'Failed to download {filename}! Unable to continue.')
continue
if local_content == remote_content:
if not silent:
logger.info(f'{filename} already up to date.')
continue
if not silent:
logger.info(f'Updating file {filename}...')
file_path.write_text(remote_content, encoding='utf-8', newline='\n')
if not silent:
logger.info(f"Checking for {'local ' if local else ''}datafile file updates...")
update_files(pathlib.Path(base_path), files_urls, silent)
@dataclass(slots=True)