Merge branch 'develop' into develop

This commit is contained in:
David Sangrey authored and GitHub committed 2026-01-02 22:24:18 -05:00
commit ecff305ef8
47 files changed
+1162 -208

No files matched your search

+6 -6
View File
@@ -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
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/
+17
View File
@@ -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 -2
View File
@@ -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")
+1 -1
View File
@@ -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
View File
@@ -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")
+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.
---
+3 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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."""
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+10 -1
View File
@@ -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."""
+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"])
+1 -1
View File
@@ -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
View File
@@ -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.
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+1 -1
View File
@@ -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.
+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
@@ -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
View File
@@ -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.
"""
+4 -4
View 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
View File
@@ -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
+7 -2
View File
@@ -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]
+1 -1
View File
@@ -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
View File
@@ -179,7 +179,7 @@
"hullMass": 230,
"reserveFuelCapacity": 0.57
},
"Caspain Explorer": {
"Caspian Explorer": {
"hullMass": 950,
"reserveFuelCapacity": 0.77
}
+1 -1
View File
@@ -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
+52 -29
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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:
+135 -36
View File
@@ -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
View File
@@ -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
View File
@@ -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