diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml index acfe7c05..2faeee80 100644 --- a/.github/workflows/windows-build.yml +++ b/.github/workflows/windows-build.yml @@ -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: ./ diff --git a/.gitignore b/.gitignore index 1df2f666..923be36e 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ \ No newline at end of file +FDevIDs/ diff --git a/ChangeLog.md b/ChangeLog.md index 7e09d56f..3cd3cfba 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -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. diff --git a/EDMC.py b/EDMC.py index 65903839..bb269170 100755 --- a/EDMC.py +++ b/EDMC.py @@ -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") diff --git a/EDMCSystemProfiler.py b/EDMCSystemProfiler.py index 123ca5fc..c4883cec 100644 --- a/EDMCSystemProfiler.py +++ b/EDMCSystemProfiler.py @@ -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 diff --git a/EDMarketConnector.py b/EDMarketConnector.py index 0c07ee55..441cd122 100644 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -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('', self.drag_start) theme_titlebar.bind('', self.drag_continue) theme_titlebar.bind('', 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( + "", + lambda e: self.resize_grip.configure(bg=frame.cget("background")), + ) + self.resize_grip.bind( + "", + 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("", _resize_start) + self.resize_grip.bind("", _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") diff --git a/L10n/en.template b/L10n/en.template index f670e39c..4ed7f42a 100644 --- a/L10n/en.template +++ b/L10n/en.template @@ -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"; diff --git a/PLUGINS.md b/PLUGINS.md index fc3d78ae..aa5fbc29 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -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. --- diff --git a/build.py b/build.py index 374562e2..a68eb8e5 100644 --- a/build.py +++ b/build.py @@ -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() diff --git a/common_utils.py b/common_utils.py index ce3c7986..8cd9a149 100644 --- a/common_utils.py +++ b/common_utils.py @@ -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 diff --git a/companion.py b/companion.py index 2c920144..b94d4446 100644 --- a/companion.py +++ b/companion.py @@ -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. diff --git a/config/__init__.py b/config/__init__.py index 10f64692..9f937f87 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -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" # # 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" diff --git a/config/linux.py b/config/linux.py index 39e98793..4c0690dd 100644 --- a/config/linux.py +++ b/config/linux.py @@ -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 diff --git a/config/windows.py b/config/windows.py index 6868493e..947a71a0 100644 --- a/config/windows.py +++ b/config/windows.py @@ -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.""" diff --git a/coriolis-update-files.py b/coriolis-update-files.py index f15d4067..f60eff75 100755 --- a/coriolis-update-files.py +++ b/coriolis-update-files.py @@ -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 diff --git a/dashboard.py b/dashboard.py index 60d5316f..9eb71dae 100644 --- a/dashboard.py +++ b/dashboard.py @@ -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 diff --git a/installer.py b/installer.py index 1ee79ddb..d7faf83f 100644 --- a/installer.py +++ b/installer.py @@ -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 diff --git a/journal_lock.py b/journal_lock.py index 8d90314e..19931752 100644 --- a/journal_lock.py +++ b/journal_lock.py @@ -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 diff --git a/killswitch.py b/killswitch.py index bec23ba8..af50a8c1 100644 --- a/killswitch.py +++ b/killswitch.py @@ -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 diff --git a/l10n.py b/l10n.py index 48197592..5f6c5298 100755 --- a/l10n.py +++ b/l10n.py @@ -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. diff --git a/loadout.py b/loadout.py index c465cdbd..2690133d 100644 --- a/loadout.py +++ b/loadout.py @@ -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 diff --git a/monitor.py b/monitor.py index bc1d44f0..b28adb44 100644 --- a/monitor.py +++ b/monitor.py @@ -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"], diff --git a/outfitting.py b/outfitting.py index 3256da31..6d51295e 100644 --- a/outfitting.py +++ b/outfitting.py @@ -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 diff --git a/plug.py b/plug.py index 9297bc53..fb0c1819 100644 --- a/plug.py +++ b/plug.py @@ -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.""" diff --git a/plugin_browser.py b/plugin_browser.py new file mode 100644 index 00000000..8122ed82 --- /dev/null +++ b/plugin_browser.py @@ -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("<>", 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"]) diff --git a/plugins/common_coreutils.py b/plugins/common_coreutils.py index 2e3b58ae..2cefba34 100644 --- a/plugins/common_coreutils.py +++ b/plugins/common_coreutils.py @@ -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. diff --git a/plugins/coriolis.py b/plugins/coriolis.py index be0bd4be..007a8472 100644 --- a/plugins/coriolis.py +++ b/plugins/coriolis.py @@ -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. diff --git a/plugins/edastro_core.py b/plugins/edastro_core.py index 6e2be692..8d2bf25f 100644 --- a/plugins/edastro_core.py +++ b/plugins/edastro_core.py @@ -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. diff --git a/plugins/eddn.py b/plugins/eddn.py index 8b783c8f..91443f53 100644 --- a/plugins/eddn.py +++ b/plugins/eddn.py @@ -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. diff --git a/plugins/edsm.py b/plugins/edsm.py index 46c8ef4e..815deb78 100644 --- a/plugins/edsm.py +++ b/plugins/edsm.py @@ -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. diff --git a/plugins/edsy.py b/plugins/edsy.py index 109bb2f0..9e0d59b4 100644 --- a/plugins/edsy.py +++ b/plugins/edsy.py @@ -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. diff --git a/plugins/inara.py b/plugins/inara.py index be3f517b..aad47ecf 100644 --- a/plugins/inara.py +++ b/plugins/inara.py @@ -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. diff --git a/plugins/spansh_core.py b/plugins/spansh_core.py index 5a3e541d..53eab113 100644 --- a/plugins/spansh_core.py +++ b/plugins/spansh_core.py @@ -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. diff --git a/prefs.py b/prefs.py index ac4c9d3c..ccb74398 100644 --- a/prefs.py +++ b/prefs.py @@ -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) diff --git a/protocol.py b/protocol.py index 774a012e..77b3abef 100644 --- a/protocol.py +++ b/protocol.py @@ -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. """ diff --git a/requirements-dev.txt b/requirements-dev.txt index fba501f3..02e199a6 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -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 diff --git a/requirements.txt b/requirements.txt index 32001a17..609ae43b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/resources/EDMC_Installer_Config_template.txt b/resources/EDMC_Installer_Config_template.txt index 06ad4ea6..37b4b286 100644 --- a/resources/EDMC_Installer_Config_template.txt +++ b/resources/EDMC_Installer_Config_template.txt @@ -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] diff --git a/scripts/build_changelog.py b/scripts/build_changelog.py index 3406ed50..4be48f1d 100644 --- a/scripts/build_changelog.py +++ b/scripts/build_changelog.py @@ -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. """ diff --git a/ships.json b/ships.json index 65a1c2df..77c01179 100644 --- a/ships.json +++ b/ships.json @@ -179,7 +179,7 @@ "hullMass": 230, "reserveFuelCapacity": 0.57 }, - "Caspain Explorer": { + "Caspian Explorer": { "hullMass": 950, "reserveFuelCapacity": 0.77 } diff --git a/stats.py b/stats.py index 67fc9379..2dcf63dd 100644 --- a/stats.py +++ b/stats.py @@ -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 diff --git a/theme.py b/theme.py index fe431ebb..a41ba528 100644 --- a/theme.py +++ b/theme.py @@ -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('', lambda e: self._enter(e, image)) widget.bind('', 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): diff --git a/timeout_session.py b/timeout_session.py index 55fc4dc0..2cfc90bb 100644 --- a/timeout_session.py +++ b/timeout_session.py @@ -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 diff --git a/ttkHyperlinkLabel.py b/ttkHyperlinkLabel.py index 3d62185d..0b1f4fa0 100644 --- a/ttkHyperlinkLabel.py +++ b/ttkHyperlinkLabel.py @@ -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: diff --git a/update.py b/update.py index 2de49eef..7357775d 100644 --- a/update.py +++ b/update.py @@ -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}") diff --git a/util/text.py b/util/text.py index 84af758b..d91b60fa 100644 --- a/util/text.py +++ b/util/text.py @@ -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 diff --git a/util_ships.py b/util_ships.py index 4f99e731..c205976c 100644 --- a/util_ships.py +++ b/util_ships.py @@ -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