mirror of
https://github.com/EDCD/EDMarketConnector.git
synced 2026-10-25 01:06:05 +03:00
Merge branch 'develop' into develop
This commit is contained in:
47 files changed
+1162
-208
No files matched your search
@@ -65,7 +65,7 @@ jobs:
|
||||
mv "../EDMarketConnector-release-${SAFE_SEMVER}.tar.gz" .
|
||||
|
||||
- name: Upload build files
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: built-files-linux
|
||||
path: |
|
||||
@@ -130,7 +130,7 @@ jobs:
|
||||
python build.py
|
||||
|
||||
- name: InnoSetup
|
||||
uses: nadeemjazmawe/inno-setup-action-cli@v6.0.5
|
||||
uses: sharktide/iscc-cli@1.0.0
|
||||
with:
|
||||
filepath: './EDMC_Installer_Config.iss'
|
||||
|
||||
@@ -142,7 +142,7 @@ jobs:
|
||||
"EDMarketConnector_Installer_Unsigned_${rest}_x86.exe"
|
||||
}
|
||||
- name: Upload build files
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: built-files-windows-x86
|
||||
path: |
|
||||
@@ -220,7 +220,7 @@ jobs:
|
||||
}
|
||||
|
||||
- name: Upload build files
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: built-files-windows-64
|
||||
path: |
|
||||
@@ -231,7 +231,7 @@ jobs:
|
||||
needs: [ windows_build_64, windows_build_86, linux_build ]
|
||||
steps:
|
||||
- name: Merge Artifacts
|
||||
uses: actions/upload-artifact/merge@v5
|
||||
uses: actions/upload-artifact/merge@v6
|
||||
with:
|
||||
name: Built files
|
||||
pattern: built-files-*
|
||||
@@ -244,7 +244,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download binary
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: Built files
|
||||
path: ./
|
||||
|
||||
+2
-1
@@ -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/
|
||||
@@ -2,6 +2,23 @@ This is the master changelog for Elite Dangerous Market Connector. Entries are
|
||||
---
|
||||
* We currently test against, and package with, Python 3.13, 64-bit.
|
||||
---
|
||||
Release 6.0.2
|
||||
===
|
||||
This release adds additional safety checks for the writing of the new config file on first write for Windows installs.
|
||||
|
||||
**Bug Fixes**
|
||||
* Fixes a bug where the new config.toml file could be improperly written with all null content.
|
||||
|
||||
Release 6.0.1
|
||||
===
|
||||
This release fixes a bug with 6.0.0 for Caspian Explorer pilots due to a typo and clarifies the license in use with EDMC.
|
||||
|
||||
**Changes and Enhancements**
|
||||
* Clarifies docstrings to more accurately reflect EDMC's use of GPL-2.0-or-later license.
|
||||
|
||||
**Bug Fixes**
|
||||
* Fixes a typo in the Ships record that would cause the Caspian Explorer to not be recorded properly.
|
||||
|
||||
Release 6.0.0
|
||||
===
|
||||
Welcome to EDMC 6.0. This build represents one of the most significant internal updates to EDMC in recent memory. It includes a complete overhaul of the configuration system, full x64-bit build support, major plugin system enhancements (including enable/disable support), new core plugins, and additional updates across the codebase.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
EDMC.py - Command-line interface. Requires prior setup through the GUI.
|
||||
|
||||
Copyright (c) EDCD, All Rights Reserved
|
||||
Licensed under the GNU General Public License.
|
||||
Licensed under the GNU General Public License v2 or later.
|
||||
See LICENSE file.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -42,7 +42,7 @@ import stats
|
||||
from commodity import COMMODITY_CSV
|
||||
from config import appcmdname, appversion, config
|
||||
from monitor import monitor
|
||||
from update import EDMCVersion, Updater, check_for_fdev_updates
|
||||
from update import EDMCVersion, Updater, check_for_fdev_updates, check_for_datafile_updates
|
||||
|
||||
sys.path.append(config.internal_plugin_dir)
|
||||
# This import must be after the sys.path.append.
|
||||
@@ -514,6 +514,7 @@ def main() -> None: # noqa: C901, CCR001
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
check_for_fdev_updates(silent=True)
|
||||
check_for_datafile_updates(silent=True)
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Ctrl+C Detected, Attempting Clean Shutdown")
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
EDMCSystemProfiler.py - GUI or Command-Line Tool to Print Diagnostic Information about EDMC.
|
||||
|
||||
Copyright (c) EDCD, All Rights Reserved
|
||||
Licensed under the GNU General Public License.
|
||||
Licensed under the GNU General Public License v2 or later.
|
||||
See LICENSE file.
|
||||
"""
|
||||
import argparse
|
||||
|
||||
+174
-81
@@ -3,7 +3,7 @@
|
||||
EDMarketConnector.py - Entry point for the GUI.
|
||||
|
||||
Copyright (c) EDCD, All Rights Reserved
|
||||
Licensed under the GNU General Public License.
|
||||
Licensed under the GNU General Public License v2 or later.
|
||||
See LICENSE file.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -22,7 +22,7 @@ import threading
|
||||
import webbrowser
|
||||
from os import chdir, environ
|
||||
from time import localtime, strftime, time
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
from typing import TYPE_CHECKING, Any, Literal, MutableMapping
|
||||
from constants import applongname, appname, protocolhandler_redirect
|
||||
|
||||
# Have this as early as possible for people running EDMarketConnector.exe
|
||||
@@ -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
|
||||
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,11 +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.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
|
||||
self.early_journal_events: list[MutableMapping[str, Any]] = []
|
||||
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)
|
||||
@@ -503,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())
|
||||
@@ -530,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(
|
||||
@@ -612,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()
|
||||
|
||||
@@ -690,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,
|
||||
@@ -715,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'):
|
||||
@@ -769,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']:
|
||||
@@ -846,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
|
||||
@@ -1413,6 +1476,19 @@ class AppWindow:
|
||||
self.cooldown()
|
||||
logger.trace_if('capi.worker', '...done')
|
||||
|
||||
def handle_plugin_event_error(self, err: str | None) -> None:
|
||||
"""
|
||||
Display a plugin's error message at the bottom of the main window.
|
||||
|
||||
If there was no error message passed in, nothing will be done.
|
||||
|
||||
:param err: Message to display, or None for no error.
|
||||
"""
|
||||
if err:
|
||||
self.status['text'] = err
|
||||
if not config.get_int('hotkey_mute'):
|
||||
hotkeymgr.play_bad()
|
||||
|
||||
def journal_event(self, event: str): # noqa: C901, CCR001 # Currently not easily broken up.
|
||||
"""
|
||||
Handle a Journal event passed through event queue from monitor.py.
|
||||
@@ -1526,15 +1602,21 @@ class AppWindow:
|
||||
|
||||
if monitor.cmdr and monitor.mode == 'CQC' and entry['event']:
|
||||
err = plug.notify_journal_entry_cqc(monitor.cmdr, monitor.is_beta, entry, monitor.state)
|
||||
if err:
|
||||
self.status['text'] = err
|
||||
if not config.get_int('hotkey_mute'):
|
||||
hotkeymgr.play_bad()
|
||||
self.handle_plugin_event_error(err)
|
||||
|
||||
return # in CQC
|
||||
|
||||
if not entry['event'] or not monitor.mode:
|
||||
# Game session has not fully loaded yet. We may be receiving events from the
|
||||
# journal, but don't know which game mode (Open/CQC/solo etc) we're running in.
|
||||
logger.trace_if('journal.queue', 'Startup, returning')
|
||||
|
||||
if entry['event']:
|
||||
# Since we don't know which mode the game is in (and therefore whether to send
|
||||
# the event to a plugin's `journal_event` or `journal_event_cqc` function),
|
||||
# temporarily queue it so we can pass it on once the mode is known.
|
||||
self.early_journal_events.append(entry)
|
||||
|
||||
return # Startup
|
||||
|
||||
if entry['event'] in ('StartUp', 'LoadGame') and monitor.started:
|
||||
@@ -1551,6 +1633,25 @@ class AppWindow:
|
||||
if not dashboard.start(self.w, monitor.started):
|
||||
logger.info("Can't start Status monitoring")
|
||||
|
||||
# monitor.cmdr should always be set if monitor.mode is set (and monitor.mode must be set
|
||||
# if we reach this point in the function), but we check monitor.cmdr here so the mypy
|
||||
# doesn't complain about passing str|None where str is required.
|
||||
if self.early_journal_events and monitor.cmdr:
|
||||
for early_entry in self.early_journal_events:
|
||||
if monitor.mode == 'CQC':
|
||||
err = plug.notify_journal_entry_cqc(monitor.cmdr, monitor.is_beta, early_entry, monitor.state)
|
||||
else:
|
||||
err = plug.notify_journal_entry(
|
||||
monitor.cmdr,
|
||||
monitor.is_beta,
|
||||
monitor.state['SystemName'],
|
||||
monitor.state['StationName'],
|
||||
early_entry,
|
||||
monitor.state
|
||||
)
|
||||
self.handle_plugin_event_error(err)
|
||||
self.early_journal_events.clear()
|
||||
|
||||
# Export loadout
|
||||
if entry['event'] == 'Loadout' and not monitor.state['Captain'] \
|
||||
and config.get_int('output') & config.OUT_SHIP:
|
||||
@@ -1566,10 +1667,7 @@ class AppWindow:
|
||||
monitor.state
|
||||
)
|
||||
|
||||
if err:
|
||||
self.status['text'] = err
|
||||
if not config.get_int('hotkey_mute'):
|
||||
hotkeymgr.play_bad()
|
||||
self.handle_plugin_event_error(err)
|
||||
|
||||
auto_update = False
|
||||
# Only if auth callback is not pending
|
||||
@@ -1652,11 +1750,7 @@ class AppWindow:
|
||||
# Currently we don't do anything with these events
|
||||
if monitor.cmdr:
|
||||
err = plug.notify_dashboard_entry(monitor.cmdr, monitor.is_beta, entry)
|
||||
|
||||
if err:
|
||||
self.status['text'] = err
|
||||
if not config.get_int('hotkey_mute'):
|
||||
hotkeymgr.play_bad()
|
||||
self.handle_plugin_event_error(err)
|
||||
|
||||
def plugin_error(self, event=None) -> None:
|
||||
"""Display asynchronous error from plugin."""
|
||||
@@ -2361,7 +2455,6 @@ sys.path: {sys.path}'''
|
||||
root.after(2, show_killswitch_poppup, root)
|
||||
# Start the main event loop
|
||||
try:
|
||||
check_for_fdev_updates()
|
||||
root.mainloop()
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Ctrl+C Detected, Attempting Clean Shutdown")
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
build.py - Build the program EXE.
|
||||
|
||||
Copyright (c) EDCD, All Rights Reserved
|
||||
Licensed under the GNU General Public License.
|
||||
Licensed under the GNU General Public License v2 or later.
|
||||
See LICENSE file.
|
||||
"""
|
||||
import os
|
||||
@@ -20,7 +20,7 @@ from config import (
|
||||
_static_appversion,
|
||||
update_interval
|
||||
)
|
||||
from update import check_for_fdev_updates
|
||||
from update import check_for_fdev_updates, check_for_datafile_updates
|
||||
|
||||
|
||||
def iss_build(template_path: str, output_file: str) -> None:
|
||||
@@ -217,4 +217,5 @@ def build() -> None:
|
||||
|
||||
if __name__ == "__main__":
|
||||
check_for_fdev_updates(local=True)
|
||||
check_for_datafile_updates(local=True)
|
||||
build()
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
common_utils.py - Common functions and modules.
|
||||
|
||||
Copyright (c) EDCD, All Rights Reserved
|
||||
Licensed under the GNU General Public License.
|
||||
Licensed under the GNU General Public License v2 or later.
|
||||
See LICENSE file.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
companion.py - Handle use of Frontier's Companion API (CAPI) service.
|
||||
|
||||
Copyright (c) EDCD, All Rights Reserved
|
||||
Licensed under the GNU General Public License.
|
||||
Licensed under the GNU General Public License v2 or later.
|
||||
See LICENSE file.
|
||||
|
||||
Deals with initiating authentication for, and use of, CAPI.
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
__init__.py - Code dealing with the configuration of the program.
|
||||
|
||||
Copyright (c) EDCD, All Rights Reserved
|
||||
Licensed under the GNU General Public License.
|
||||
Licensed under the GNU General Public License v2 or later.
|
||||
See LICENSE file.
|
||||
|
||||
Windows uses the Registry to store values in a flat manner.
|
||||
@@ -54,7 +54,7 @@ appcmdname = "EDMC"
|
||||
# <https://semver.org/#semantic-versioning-specification-semver>
|
||||
# Major.Minor.Patch(-prerelease)(+buildmetadata)
|
||||
# NB: Do *not* import this, use the functions appversion() and appversion_nobuild()
|
||||
_static_appversion = "6.0.0"
|
||||
_static_appversion = "6.0.2"
|
||||
_cached_version: semantic_version.Version | None = None
|
||||
copyright = "© 2015-2019 Jonathan Harris, 2020-2025 EDCD"
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
linux.py - Linux config implementation.
|
||||
|
||||
Copyright (c) EDCD, All Rights Reserved
|
||||
Licensed under the GNU General Public License.
|
||||
Licensed under the GNU General Public License v2 or later.
|
||||
See LICENSE file.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
+20
-3
@@ -2,13 +2,14 @@
|
||||
windows.py - Windows config implementation.
|
||||
|
||||
Copyright (c) EDCD, All Rights Reserved
|
||||
Licensed under the GNU General Public License.
|
||||
Licensed under the GNU General Public License v2 or later.
|
||||
See LICENSE file.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import sys
|
||||
import os
|
||||
import uuid
|
||||
import winreg
|
||||
import datetime
|
||||
@@ -88,9 +89,25 @@ class WinConfigMinimal:
|
||||
|
||||
path_obj = pathlib.Path(toml_path)
|
||||
path_obj.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = path_obj.with_suffix(path_obj.suffix + ".tmp")
|
||||
config_logger.debug("Writing Config File")
|
||||
with path_obj.open("wb") as f:
|
||||
tomli_w.dump(config_data, f)
|
||||
try:
|
||||
# Validate serialization BEFORE touching the real file
|
||||
tomli_w.dumps(config_data)
|
||||
with tmp_path.open("wb") as f:
|
||||
tomli_w.dump(config_data, f)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
tmp_path.replace(path_obj)
|
||||
except Exception:
|
||||
config_logger.exception("Failed to write TOML config file")
|
||||
# Ensure temp file doesn't linger
|
||||
try:
|
||||
if tmp_path.exists():
|
||||
tmp_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
def close(self):
|
||||
"""Close registry handle."""
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
coriolis-update-files.py - Build ship and module databases from https://github.com/EDCD/coriolis-data/.
|
||||
|
||||
Copyright (c) EDCD, All Rights Reserved
|
||||
Licensed under the GNU General Public License.
|
||||
Licensed under the GNU General Public License v2 or later.
|
||||
See LICENSE file.
|
||||
|
||||
This script also utilizes the file outfitting.csv. Due to how collate.py
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
dashboard.py - Handle the game Status.json file.
|
||||
|
||||
Copyright (c) EDCD, All Rights Reserved
|
||||
Licensed under the GNU General Public License.
|
||||
Licensed under the GNU General Public License v2 or later.
|
||||
See LICENSE file.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
installer.py - Build the Installer.
|
||||
|
||||
Copyright (c) EDCD, All Rights Reserved
|
||||
Licensed under the GNU General Public License.
|
||||
Licensed under the GNU General Public License v2 or later.
|
||||
See LICENSE file.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
journal_lock.py - Locking of the Journal Directory.
|
||||
|
||||
Copyright (c) EDCD, All Rights Reserved
|
||||
Licensed under the GNU General Public License.
|
||||
Licensed under the GNU General Public License v2 or later.
|
||||
See LICENSE file.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
killswitch.py - Fetch kill switches from EDMC Repo.
|
||||
|
||||
Copyright (c) EDCD, All Rights Reserved
|
||||
Licensed under the GNU General Public License.
|
||||
Licensed under the GNU General Public License v2 or later.
|
||||
See LICENSE file.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
l10n.py - Localize using OSX-Style Strings.
|
||||
|
||||
Copyright (c) EDCD, All Rights Reserved
|
||||
Licensed under the GNU General Public License.
|
||||
Licensed under the GNU General Public License v2 or later.
|
||||
See LICENSE file.
|
||||
|
||||
Localization with gettext is a pain on non-Unix systems.
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
loadout.py - Export ship loadout in Companion API json format.
|
||||
|
||||
Copyright (c) EDCD, All Rights Reserved
|
||||
Licensed under the GNU General Public License.
|
||||
Licensed under the GNU General Public License v2 or later.
|
||||
See LICENSE file.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
+14
-6
@@ -2,7 +2,7 @@
|
||||
monitor.py - Monitor for new Journal files and contents of latest.
|
||||
|
||||
Copyright (c) EDCD, All Rights Reserved
|
||||
Licensed under the GNU General Public License.
|
||||
Licensed under the GNU General Public License v2 or later.
|
||||
See LICENSE file.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -589,6 +589,7 @@ class EDLogs(FileSystemEventHandler):
|
||||
elif event_type == 'commander':
|
||||
self.live = True # First event in 3.0
|
||||
self.cmdr = entry['Name']
|
||||
self.mode = None
|
||||
self.state['FID'] = entry['FID']
|
||||
logger.trace_if(STARTUP, f'"Commander" event, {monitor.cmdr=}, {monitor.state["FID"]=}')
|
||||
|
||||
@@ -720,11 +721,18 @@ class EDLogs(FileSystemEventHandler):
|
||||
data_dict = {}
|
||||
for module in entry['Modules']:
|
||||
if module.get('Slot') == 'FuelTank':
|
||||
cap = module['Item'].split('size')
|
||||
cap = cap[1].split('_')
|
||||
cap = 2 ** int(cap[0])
|
||||
ship = ship_name_map.get(entry["Ship"], entry["Ship"])
|
||||
fuel = {'Main': cap, 'Reserve': ships[ship]['reserveFuelCapacity']}
|
||||
fuel = self.state["FuelCapacity"]
|
||||
|
||||
if fuel["Main"] is None or fuel["Reserve"] is None:
|
||||
cap = module['Item'].split('size')
|
||||
cap = cap[1].split('_')
|
||||
cap = 2 ** int(cap[0])
|
||||
ship = ship_name_map.get(entry["Ship"], entry["Ship"])
|
||||
fuel = {
|
||||
'Main': cap,
|
||||
'Reserve': ships.get(ship, {}).get('reserveFuelCapacity', 0.25)
|
||||
}
|
||||
|
||||
data_dict.update({"FuelCapacity": fuel})
|
||||
data_dict.update({
|
||||
'Ship': entry["Ship"],
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
outfitting.py - Code dealing with ship outfitting.
|
||||
|
||||
Copyright (c) EDCD, All Rights Reserved
|
||||
Licensed under the GNU General Public License.
|
||||
Licensed under the GNU General Public License v2 or later.
|
||||
See LICENSE file.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
plug.py - Plugin API.
|
||||
|
||||
Copyright (c) EDCD, All Rights Reserved
|
||||
Licensed under the GNU General Public License.
|
||||
Licensed under the GNU General Public License v2 or later.
|
||||
See LICENSE file.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"])
|
||||
@@ -2,7 +2,7 @@
|
||||
common_coreutils.py - Common Plugin Functions.
|
||||
|
||||
Copyright (c) EDCD, All Rights Reserved
|
||||
Licensed under the GNU General Public License.
|
||||
Licensed under the GNU General Public License v2 or later.
|
||||
See LICENSE file.
|
||||
|
||||
This is an EDMC 'core' plugin.
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
coriolis.py - Coriolis Ship Export.
|
||||
|
||||
Copyright (c) EDCD, All Rights Reserved
|
||||
Licensed under the GNU General Public License.
|
||||
Licensed under the GNU General Public License v2 or later.
|
||||
See LICENSE file.
|
||||
|
||||
This is an EDMC 'core' plugin.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
edastro_core.py - Exporting Data to EDAstro.
|
||||
|
||||
Copyright (c) EDCD, All Rights Reserved
|
||||
Licensed under the GNU General Public License.
|
||||
Licensed under the GNU General Public License v2 or later.
|
||||
See LICENSE file.
|
||||
|
||||
This is an EDMC 'core' plugin.
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
eddn.py - Exporting Data to EDDN.
|
||||
|
||||
Copyright (c) EDCD, All Rights Reserved
|
||||
Licensed under the GNU General Public License.
|
||||
Licensed under the GNU General Public License v2 or later.
|
||||
See LICENSE file.
|
||||
|
||||
This is an EDMC 'core' plugin.
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
edsm.py - Handling EDSM Data and Display.
|
||||
|
||||
Copyright (c) EDCD, All Rights Reserved
|
||||
Licensed under the GNU General Public License.
|
||||
Licensed under the GNU General Public License v2 or later.
|
||||
See LICENSE file.
|
||||
|
||||
This is an EDMC 'core' plugin.
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
edsy.py - Exporting Data to EDSY.
|
||||
|
||||
Copyright (c) EDCD, All Rights Reserved
|
||||
Licensed under the GNU General Public License.
|
||||
Licensed under the GNU General Public License v2 or later.
|
||||
See LICENSE file.
|
||||
|
||||
This is an EDMC 'core' plugin.
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
inara.py - Sync with INARA.
|
||||
|
||||
Copyright (c) EDCD, All Rights Reserved
|
||||
Licensed under the GNU General Public License.
|
||||
Licensed under the GNU General Public License v2 or later.
|
||||
See LICENSE file.
|
||||
|
||||
This is an EDMC 'core' plugin.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
spansh_core.py - Spansh URL provider.
|
||||
|
||||
Copyright (c) EDCD, All Rights Reserved
|
||||
Licensed under the GNU General Public License.
|
||||
Licensed under the GNU General Public License v2 or later.
|
||||
See LICENSE file.
|
||||
|
||||
This is an EDMC 'core' plugin.
|
||||
|
||||
@@ -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
|
||||
@@ -239,7 +240,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):
|
||||
@@ -292,6 +293,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
|
||||
@@ -1010,9 +1012,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)
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
protocol.py - Protocol Handler for cAPI Auth.
|
||||
|
||||
Copyright (c) EDCD, All Rights Reserved
|
||||
Licensed under the GNU General Public License.
|
||||
Licensed under the GNU General Public License v2 or later.
|
||||
See LICENSE file.
|
||||
"""
|
||||
|
||||
|
||||
@@ -13,17 +13,17 @@ flake8-json==24.4.0
|
||||
flake8-noqa==1.4.0
|
||||
flake8-use-fstring==1.4
|
||||
|
||||
mypy==1.19.0
|
||||
mypy==1.19.1
|
||||
pep8-naming==0.15.1
|
||||
safety==3.7.0
|
||||
types-requests==2.32.4.20250913
|
||||
types-setuptools==80.9.0.20250822
|
||||
types-setuptools==80.9.0.20251223
|
||||
|
||||
# Code formatting tools
|
||||
autopep8==2.3.2
|
||||
|
||||
# Git pre-commit checking
|
||||
pre-commit==4.5.0
|
||||
pre-commit==4.5.1
|
||||
|
||||
# HTML changelogs
|
||||
mistune==3.1.4
|
||||
@@ -35,7 +35,7 @@ py2exe==0.14.0.0; sys_platform == 'win32'
|
||||
# Testing
|
||||
pytest==9.0.1
|
||||
pytest-cov==7.0.0 # Pytest code coverage support
|
||||
coverage[toml]==7.12.0 # pytest-cov dep. This is here to ensure that it includes TOML support for pyproject.toml configs
|
||||
coverage[toml]==7.13.1 # pytest-cov dep. This is here to ensure that it includes TOML support for pyproject.toml configs
|
||||
coverage-conditional-plugin==0.9.0
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -5,5 +5,5 @@ simplesystray==0.1.0; sys_platform == 'win32'
|
||||
semantic-version==2.10.0
|
||||
# For manipulating folder permissions and the like.
|
||||
pywin32==311; sys_platform == 'win32'
|
||||
psutil==7.1.3
|
||||
psutil==7.2.1
|
||||
tomli-w==1.2.0
|
||||
@@ -6,6 +6,7 @@
|
||||
#define MyAppURL "https://edcd.github.io/"
|
||||
#define SuppURL "https://github.com/EDCD/EDMarketConnector/"
|
||||
#define MyAppExeName "EDMarketConnector.exe"
|
||||
#define MyAppCopyright "Copyright (C) 2015-2019 Jonathan Harris, 2020-2026 EDCD"
|
||||
|
||||
[Setup]
|
||||
AppId={{5E9AD4D3-0365-41D5-9586-9368745DD109}
|
||||
@@ -16,7 +17,7 @@ AppPublisher={#MyAppPublisher}
|
||||
AppPublisherURL={#MyAppURL}
|
||||
AppSupportURL={#SuppURL}
|
||||
AppUpdatesURL={#SuppURL}
|
||||
AppCopyright=Copyright (C) 2015-2019 Jonathan Harris, 2020-2024 EDCD
|
||||
AppCopyright={#MyAppCopyright}
|
||||
AllowUNCPath=no
|
||||
AllowNetworkDrive=no
|
||||
DefaultDirName={autopf}\{#MyAppName}
|
||||
@@ -36,6 +37,11 @@ UninstallDisplayIcon={app}\{#MyAppExeName}
|
||||
MinVersion=6.2
|
||||
ChangesAssociations = yes
|
||||
UsedUserAreasWarning = no
|
||||
VersionInfoVersion={#MyAppVersion}
|
||||
VersionInfoCompany={#MyAppPublisher}
|
||||
VersionInfoDescription={#MyAppLongName}
|
||||
VersionInfoCopyright={#MyAppCopyright}
|
||||
|
||||
|
||||
[Languages]
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
@@ -44,7 +50,6 @@ Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
|
||||
|
||||
[Files]
|
||||
Source: "dist.win32\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "dist.win32\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
|
||||
[Icons]
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
build_changelog.py - Read the latest changelog file and format for the Forums and Reddit.
|
||||
|
||||
Copyright (c) EDCD, All Rights Reserved
|
||||
Licensed under the GNU General Public License.
|
||||
Licensed under the GNU General Public License v2 or later.
|
||||
See LICENSE file.
|
||||
"""
|
||||
|
||||
|
||||
+1
-1
@@ -179,7 +179,7 @@
|
||||
"hullMass": 230,
|
||||
"reserveFuelCapacity": 0.57
|
||||
},
|
||||
"Caspain Explorer": {
|
||||
"Caspian Explorer": {
|
||||
"hullMass": 950,
|
||||
"reserveFuelCapacity": 0.77
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
stats.py - CMDR Status Information.
|
||||
|
||||
Copyright (c) EDCD, All Rights Reserved
|
||||
Licensed under the GNU General Public License.
|
||||
Licensed under the GNU General Public License v2 or later.
|
||||
See LICENSE file.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
theme.py - Theme support.
|
||||
|
||||
Copyright (c) EDCD, All Rights Reserved
|
||||
Licensed under the GNU General Public License.
|
||||
Licensed under the GNU General Public License v2 or later.
|
||||
See LICENSE file.
|
||||
|
||||
Because of various ttk limitations this app is an unholy mix of Tk and ttk widgets.
|
||||
@@ -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):
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
timeout_session.py - requests session with timeout adapter.
|
||||
|
||||
Copyright (c) EDCD, All Rights Reserved
|
||||
Licensed under the GNU General Public License.
|
||||
Licensed under the GNU General Public License v2 or later.
|
||||
See LICENSE file.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
ttkHyperlinkLabel.py - Clickable ttk labels.
|
||||
|
||||
Copyright (c) EDCD, All Rights Reserved
|
||||
Licensed under the GNU General Public License.
|
||||
Licensed under the GNU General Public License v2 or later.
|
||||
See LICENSE file.
|
||||
|
||||
In addition to standard ttk.Label arguments, takes the following arguments:
|
||||
|
||||
@@ -2,16 +2,19 @@
|
||||
update.py - Checking for Program Updates.
|
||||
|
||||
Copyright (c) EDCD, All Rights Reserved
|
||||
Licensed under the GNU General Public License.
|
||||
Licensed under the GNU General Public License v2 or later.
|
||||
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,24 +163,23 @@ 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)
|
||||
|
||||
for filename, url in files_urls.items():
|
||||
file_path = fdevid_dir / filename
|
||||
local_content = read_normalized_file(file_path) or copy_bundle_file(filename, fdevid_dir)
|
||||
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'FDEV ID file {filename} already up to date.')
|
||||
continue
|
||||
|
||||
if not silent:
|
||||
logger.info(f'Updating FDEV ID file {filename}...')
|
||||
file_path.write_text(remote_content, encoding='utf-8', newline='\n')
|
||||
def check_for_datafile_updates(silent: bool = False, local: bool = False) -> None:
|
||||
"""Check for and download data file updates."""
|
||||
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',
|
||||
'master_plugin_list.json': 'https://raw.githubusercontent.com/Rixxan/'
|
||||
'EDMC_Plugin_Registry_Dev/refs/heads/master/master_plugin_list.json'
|
||||
}
|
||||
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)
|
||||
@@ -145,11 +240,13 @@ class Updater:
|
||||
|
||||
# Always trigger FDEV checks here too
|
||||
check_for_fdev_updates()
|
||||
check_for_datafile_updates()
|
||||
try:
|
||||
check_for_fdev_updates(local=True)
|
||||
check_for_datafile_updates(local=True)
|
||||
except Exception as e:
|
||||
logger.info(
|
||||
"Tried to update bundle FDEV files but failed. Don't worry, "
|
||||
"Tried to update bundle files but failed. Don't worry, "
|
||||
"this likely isn't important and can be ignored unless "
|
||||
f"you run into other issues. If you're curious: {e}"
|
||||
)
|
||||
@@ -219,10 +316,12 @@ class Updater:
|
||||
self.updater.win_sparkle_check_update_with_ui()
|
||||
|
||||
check_for_fdev_updates()
|
||||
check_for_datafile_updates()
|
||||
try:
|
||||
check_for_fdev_updates(local=True)
|
||||
check_for_datafile_updates(local=True)
|
||||
except Exception as e:
|
||||
logger.info("Tried to update bundle FDEV files but failed. Don't worry, "
|
||||
logger.info("Tried to update bundle files but failed. Don't worry, "
|
||||
"this likely isn't important and can be ignored unless"
|
||||
f" you run into other issues. If you're curious: {e}")
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
text.py - Dealing with Text and Bytes.
|
||||
|
||||
Copyright (c) EDCD, All Rights Reserved
|
||||
Licensed under the GNU General Public License.
|
||||
Licensed under the GNU General Public License v2 or later.
|
||||
See LICENSE file.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
util_ships.py - Ship Utilities.
|
||||
|
||||
Copyright (c) EDCD, All Rights Reserved
|
||||
Licensed under the GNU General Public License.
|
||||
Licensed under the GNU General Public License v2 or later.
|
||||
See LICENSE file.
|
||||
"""
|
||||
import os
|
||||
|
||||
Reference in new issue
Block a user