From fbc6933aa7c314505a22ac15136958d6217f0815 Mon Sep 17 00:00:00 2001 From: David Sangrey Date: Tue, 24 Sep 2024 19:42:17 -0400 Subject: [PATCH 01/32] Update ChangeLog.md --- ChangeLog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ChangeLog.md b/ChangeLog.md index 7d289839..5ff403ae 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -10,7 +10,7 @@ Release 5.12.0 === This release brings a number of performance enhancements and functionality updates requested by the community to EDMC. -Notably, integration with Inara's SLEF notation, custom pugin directories, streamlined logging locations, and +Notably, integration with Inara's SLEF notation, custom plugin directories, streamlined logging locations, and performance enhancements are included. This release also fixes a few administrative issues regarding licenses to ensure compliance with included libraries. From 42bc097e92cb67ea135d7db8280292e63b1955b1 Mon Sep 17 00:00:00 2001 From: Inutt <1228850-inutt@users.noreply.gitlab.com> Date: Fri, 28 Nov 2025 03:37:14 +0000 Subject: [PATCH 02/32] Pass events prior to initial LoadGame event to plugins Done by temporarily buffering events before monitor.mode is set, then notifying plugins about them before sending the first event where monitor.mode has a value. LoadGame is currently the only event that sets monitor.mode. Fixes #2286 diff --git a/EDMarketConnector.py b/EDMarketConnector.py index e7bdf490..d53b625d 100644 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -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 @@ -458,6 +458,8 @@ class AppWindow: def __init__(self, master: tk.Tk): # noqa: C901, CCR001 # TODO - can possibly factor something out + self.early_journal_events: list[MutableMapping[str, Any]] = [] + self.capi_query_holdoff_time = config.get_int('querytime', default=0) + companion.capi_query_cooldown self.capi_fleetcarrier_query_holdoff_time = config.get_int('fleetcarrierquerytime', default=0) \ + companion.capi_fleetcarrier_query_cooldown @@ -1400,6 +1402,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. @@ -1513,15 +1528,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: @@ -1538,6 +1559,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: @@ -1553,10 +1593,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 @@ -1638,11 +1675,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.""" --- EDMarketConnector.py | 61 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 47 insertions(+), 14 deletions(-) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index e7bdf490..d53b625d 100644 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -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 @@ -458,6 +458,8 @@ class AppWindow: def __init__(self, master: tk.Tk): # noqa: C901, CCR001 # TODO - can possibly factor something out + self.early_journal_events: list[MutableMapping[str, Any]] = [] + self.capi_query_holdoff_time = config.get_int('querytime', default=0) + companion.capi_query_cooldown self.capi_fleetcarrier_query_holdoff_time = config.get_int('fleetcarrierquerytime', default=0) \ + companion.capi_fleetcarrier_query_cooldown @@ -1400,6 +1402,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. @@ -1513,15 +1528,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: @@ -1538,6 +1559,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: @@ -1553,10 +1593,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 @@ -1638,11 +1675,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.""" From e41317245676936f02da1140450f2148742fcce1 Mon Sep 17 00:00:00 2001 From: Inutt <1228850-inutt@users.noreply.gitlab.com> Date: Fri, 28 Nov 2025 16:59:42 +0000 Subject: [PATCH 03/32] Unset monitor.mode on Commander journal event to avoid reusing previous mode Changing game mode would otherwise re-use the previously used game mode for events before the new LoadGame event. This resets the mode on the first available event so they are buffered again until the new game mode is determined. --- monitor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/monitor.py b/monitor.py index 3df0f85c..ee94d2f1 100644 --- a/monitor.py +++ b/monitor.py @@ -588,6 +588,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"]=}') From 9a275b31c50b141de4f47ec373ca2c45a3b0c168 Mon Sep 17 00:00:00 2001 From: Ben Peddell Date: Sat, 4 Oct 2025 22:04:43 +1000 Subject: [PATCH 04/32] [Bugfix] Prefer loadout fuel capacity if valid Fixes a key error when processing the `Loadout` event from the journal when the ship in the loadout is not in the `ship_name_map` --- monitor.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/monitor.py b/monitor.py index bc1d44f0..312aa5f4 100644 --- a/monitor.py +++ b/monitor.py @@ -720,11 +720,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"], From beb68efae89b110d969c034df3ea8a20093d0449 Mon Sep 17 00:00:00 2001 From: David Sangrey Date: Thu, 25 Dec 2025 01:36:24 -0500 Subject: [PATCH 05/32] [2568] Fix Ships JSON Typo --- ChangeLog.md | 10 ++++++++++ EDMC.py | 2 +- EDMCSystemProfiler.py | 2 +- EDMarketConnector.py | 2 +- build.py | 2 +- common_utils.py | 2 +- companion.py | 2 +- config/__init__.py | 4 ++-- config/linux.py | 2 +- config/windows.py | 2 +- coriolis-update-files.py | 2 +- dashboard.py | 2 +- installer.py | 2 +- journal_lock.py | 2 +- killswitch.py | 2 +- l10n.py | 2 +- loadout.py | 2 +- monitor.py | 2 +- outfitting.py | 2 +- plug.py | 2 +- plugins/common_coreutils.py | 2 +- plugins/coriolis.py | 2 +- plugins/edastro_core.py | 2 +- plugins/eddn.py | 2 +- plugins/edsm.py | 2 +- plugins/edsy.py | 2 +- plugins/inara.py | 2 +- plugins/spansh_core.py | 2 +- protocol.py | 2 +- scripts/build_changelog.py | 2 +- ships.json | 2 +- stats.py | 2 +- theme.py | 2 +- timeout_session.py | 2 +- ttkHyperlinkLabel.py | 2 +- update.py | 2 +- util/text.py | 2 +- util_ships.py | 2 +- 38 files changed, 48 insertions(+), 38 deletions(-) diff --git a/ChangeLog.md b/ChangeLog.md index 7e09d56f..e8a7d27e 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -2,6 +2,16 @@ 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.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 poperly. + 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 fb5dda13..022699ae 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 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 37e2ab4d..e371f3f2 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 diff --git a/build.py b/build.py index 374562e2..c8e8a7a2 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 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..d859c8f3 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.1" _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..ae727c33 100644 --- a/config/windows.py +++ b/config/windows.py @@ -2,7 +2,7 @@ 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 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..2a82aa8f 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 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..fd6b804e 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 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/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/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..68a1a391 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. 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..a0ccca04 100644 --- a/update.py +++ b/update.py @@ -2,7 +2,7 @@ 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 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 From 158c98f630b60198b22ca25121b42d9f44e854bf Mon Sep 17 00:00:00 2001 From: David Sangrey Date: Thu, 25 Dec 2025 01:58:40 -0500 Subject: [PATCH 06/32] Update ChangeLog.md --- ChangeLog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ChangeLog.md b/ChangeLog.md index e8a7d27e..b6b80e66 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -10,7 +10,7 @@ This release fixes a bug with 6.0.0 for Caspian Explorer pilots due to a typo an * 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 poperly. +* Fixes a typo in the Ships record that would cause the Caspian Explorer to not be recorded properly. Release 6.0.0 === From f61b319fcdd2592be312755ab304820b8edb0e3d Mon Sep 17 00:00:00 2001 From: David Sangrey Date: Thu, 25 Dec 2025 02:27:12 -0500 Subject: [PATCH 07/32] [#2570] Datafile Updater --- EDMC.py | 3 ++- EDMarketConnector.py | 3 ++- build.py | 3 ++- update.py | 31 +++++++++++++++++++++++++------ 4 files changed, 31 insertions(+), 9 deletions(-) diff --git a/EDMC.py b/EDMC.py index 022699ae..9adef491 100755 --- a/EDMC.py +++ b/EDMC.py @@ -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. @@ -502,6 +502,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/EDMarketConnector.py b/EDMarketConnector.py index e371f3f2..50c57d90 100644 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -67,7 +67,7 @@ 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 update import check_for_fdev_updates, check_for_datafile_updates from common_utils import log_locale, SERVER_RETRY if __name__ == '__main__': # noqa: C901 @@ -2350,6 +2350,7 @@ sys.path: {sys.path}''' # Start the main event loop try: check_for_fdev_updates() + check_for_datafile_updates() root.mainloop() except KeyboardInterrupt: logger.info("Ctrl+C Detected, Attempting Clean Shutdown") diff --git a/build.py b/build.py index c8e8a7a2..a68eb8e5 100644 --- a/build.py +++ b/build.py @@ -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/update.py b/update.py index a0ccca04..4c1b5dd8 100644 --- a/update.py +++ b/update.py @@ -68,9 +68,24 @@ def check_for_fdev_updates(silent: bool = False, local: bool = False) -> None: 'rare_commodity.csv': 'https://raw.githubusercontent.com/EDCD/FDevIDs/master/rare_commodity.csv' } + update_files(fdevid_dir, files_urls, silent) + + +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' + } + update_files(base_path, files_urls, silent) + + +def update_files(directory: pathlib.Path, files_urls: dict[str, str], silent: bool = False) -> None: + """Update the provided files from a remote source.""" for filename, url in files_urls.items(): - file_path = fdevid_dir / filename - local_content = read_normalized_file(file_path) or copy_bundle_file(filename, fdevid_dir) + file_path = directory / filename + local_content = read_normalized_file(file_path) or copy_bundle_file(filename, directory) remote_content = fetch_remote_file(url) if not remote_content: if not silent: @@ -79,11 +94,11 @@ def check_for_fdev_updates(silent: bool = False, local: bool = False) -> None: if local_content == remote_content: if not silent: - logger.info(f'FDEV ID file {filename} already up to date.') + logger.info(f'{filename} already up to date.') continue if not silent: - logger.info(f'Updating FDEV ID file {filename}...') + logger.info(f'Updating file {filename}...') file_path.write_text(remote_content, encoding='utf-8', newline='\n') @@ -145,11 +160,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 +236,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}") From 6192803d6198f63d088f4c5a4f0c5623bdb39dee Mon Sep 17 00:00:00 2001 From: David Sangrey Date: Thu, 25 Dec 2025 17:18:15 -0500 Subject: [PATCH 08/32] Add VERSION check --- PLUGINS.md | 10 ++++++++++ browser.py | 11 +++++++++++ plug.py | 10 ++++++++++ 3 files changed, 31 insertions(+) create mode 100644 browser.py 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/browser.py b/browser.py new file mode 100644 index 00000000..b911e282 --- /dev/null +++ b/browser.py @@ -0,0 +1,11 @@ +""" +browser.py - 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 plug +# from config import config, logger +# from l10n import translations as tr diff --git a/plug.py b/plug.py index fd6b804e..efd97ee5 100644 --- a/plug.py +++ b/plug.py @@ -177,6 +177,16 @@ 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.""" From 08046185d5970665b85cb230a7622e090db599bf Mon Sep 17 00:00:00 2001 From: David Sangrey Date: Thu, 25 Dec 2025 18:26:59 -0500 Subject: [PATCH 09/32] Add Master Plugin List Dev Endpoint --- .gitignore | 3 ++- update.py | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) 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/update.py b/update.py index 4c1b5dd8..c44a9e9a 100644 --- a/update.py +++ b/update.py @@ -76,7 +76,9 @@ def check_for_datafile_updates(silent: bool = False, local: bool = False) -> Non base_path = config.respath_path if local else config.app_dir_path files_urls = { 'modules.json': 'https://raw.githubusercontent.com/EDCD/EDMarketConnector/refs/heads/releases/modules.json', - 'ships.json': 'https://raw.githubusercontent.com/EDCD/EDMarketConnector/refs/heads/releases/ships.json' + 'ships.json': 'https://raw.githubusercontent.com/EDCD/EDMarketConnector/refs/heads/releases/ships.json', + 'master_plugin_list.json': 'https://raw.githubusercontent.com/Rixxan/' + 'EDMC_Plugin_Registry_Dev/refs/heads/master/master_plugin_list.json' } update_files(base_path, files_urls, silent) From 4feb2885068d29c890c6db899d1c7e6f9f483652 Mon Sep 17 00:00:00 2001 From: David Sangrey Date: Fri, 26 Dec 2025 11:22:31 -0500 Subject: [PATCH 10/32] [#2567] Improve TOML Dump Safety --- config/windows.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/config/windows.py b/config/windows.py index ae727c33..947a71a0 100644 --- a/config/windows.py +++ b/config/windows.py @@ -9,6 +9,7 @@ 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.""" From d8eed62e08ff830a0d3fd35a9f812203a8197df9 Mon Sep 17 00:00:00 2001 From: David Sangrey Date: Fri, 26 Dec 2025 11:22:31 -0500 Subject: [PATCH 11/32] [#2567] Improve TOML Dump Safety --- config/windows.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/config/windows.py b/config/windows.py index ae727c33..947a71a0 100644 --- a/config/windows.py +++ b/config/windows.py @@ -9,6 +9,7 @@ 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.""" From e1d64c95c605462e8a6726686eba9632f88c932a Mon Sep 17 00:00:00 2001 From: David Sangrey Date: Fri, 26 Dec 2025 11:58:10 -0500 Subject: [PATCH 12/32] [RELEASE] 6.0.2 --- ChangeLog.md | 7 +++++++ config/__init__.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/ChangeLog.md b/ChangeLog.md index b6b80e66..3cd3cfba 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -2,6 +2,13 @@ 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. diff --git a/config/__init__.py b/config/__init__.py index d859c8f3..9f937f87 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -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.1" +_static_appversion = "6.0.2" _cached_version: semantic_version.Version | None = None copyright = "© 2015-2019 Jonathan Harris, 2020-2025 EDCD" From 4112a2110b87cdd16389d1ea50486e53b29df825 Mon Sep 17 00:00:00 2001 From: David Sangrey Date: Fri, 26 Dec 2025 13:48:12 -0500 Subject: [PATCH 13/32] [Minor] Improve Local File Updater This helps with speed and prevents blocking of EDMC load. --- update.py | 152 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 114 insertions(+), 38 deletions(-) diff --git a/update.py b/update.py index c44a9e9a..15bea7db 100644 --- a/update.py +++ b/update.py @@ -8,10 +8,13 @@ See LICENSE file. from __future__ import annotations import pathlib +import hashlib import shutil import sys import threading import os +import time +from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass from tkinter import messagebox from traceback import print_exc @@ -28,35 +31,128 @@ if TYPE_CHECKING: logger = get_main_logger() +HTTP_TIMEOUT = (5, 20) # (connect, read) +HTTP_RETRIES = 3 +RETRY_BACKOFF = 1.5 # seconds multiplier +MAX_WORKERS = 8 -def read_normalized_file(path: pathlib.Path) -> str: + +def read_normalized_file(path: pathlib.Path) -> tuple[str, str, str] | None: """Read a UTF-8 (with BOM-safe) file and normalize line endings and whitespace.""" try: - return path.read_bytes().decode('utf-8-sig').replace('\r\n', '\n').strip() + raw = path.read_bytes() except FileNotFoundError: - return '' + return None + newline = '\r\n' if b'\r\n' in raw else '\n' + text = raw.decode('utf-8-sig') + normalized = text.replace('\r\n', '\n').strip() + + return normalized, hashlib.sha256(normalized.encode()).hexdigest(), newline -def copy_bundle_file(filename: str, dest_dir: pathlib.Path) -> str: - """Attempt to copy a missing file from the bundled FDevIDs folder.""" +def copy_bundle_file(filename: str, dest_dir: pathlib.Path) -> tuple[str, str] | None: + """ + Attempt to copy a missing file from the bundled FDevIDs folder. + + Returns (content, hash) or None. + """ try: - shutil.copy(f"FDevIDs/{filename}", dest_dir) - return read_normalized_file(dest_dir / filename) + dest = dest_dir / filename + shutil.copy(f"FDevIDs/{filename}", dest) + return read_normalized_file(dest) # type: ignore except (FileNotFoundError, shutil.SameFileError): logger.info("Bundle copy failed or identical; continuing with empty content.") - return '' - - -def fetch_remote_file(url: str) -> str | None: - """Fetch a remote file and normalize its content.""" - try: - response = requests.get(url, timeout=20) - response.raise_for_status() - return response.text.replace('\r\n', '\n').strip() - except requests.RequestException: return None +def fetch_remote_file(url: str) -> tuple[str, str] | None: + """ + Fetch a remote file with retries. + + Returns (normalized_text, hash) or None on failure. + """ + delay = 1.0 + + for attempt in range(1, HTTP_RETRIES + 1): + try: + response = requests.get(url, timeout=HTTP_TIMEOUT) + response.raise_for_status() + + text = response.text.replace('\r\n', '\n').strip() + return text, hashlib.sha256(text.encode()).hexdigest() + + except requests.RequestException as exc: + if attempt >= HTTP_RETRIES: + logger.debug("Download failed after %d attempts: %s", attempt, exc) + return None + + time.sleep(delay) + delay *= RETRY_BACKOFF + return None + + +def update_single_file( + directory: pathlib.Path, + filename: str, + url: str, + silent: bool = False, +) -> None: + """Update a single bundle file.""" + file_path = directory / filename + + local = read_normalized_file(file_path) + if local is None: + local = copy_bundle_file(filename, directory) # type: ignore + + if local: + local_text, local_hash, newline = local + else: + local_text, local_hash, newline = '', '', '\n' # noqa: F841 + + remote = fetch_remote_file(url) + if not remote: + if not silent: + logger.error(f'Failed to download {filename}! Unable to continue.') + return + + remote_text, remote_hash = remote + + if local_hash == remote_hash: + if not silent: + logger.info(f'{filename} already up to date.') + return + + if not silent: + logger.info(f'Updating file {filename}...') + + # Restore original newline style + output = remote_text.replace('\n', newline) + file_path.write_text(output, encoding='utf-8', newline='') + + +def update_files( + directory: pathlib.Path, + files_urls: dict[str, str], + silent: bool = False, +) -> None: + """Start threads to update bundle files.""" + directory.mkdir(parents=True, exist_ok=True) + + max_workers = min(MAX_WORKERS, len(files_urls)) + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = [ + executor.submit(update_single_file, directory, filename, url, silent) + for filename, url in files_urls.items() + ] + + for future in as_completed(futures): + try: + future.result() + except Exception: + logger.exception("Unexpected error while updating files") + + def check_for_fdev_updates(silent: bool = False, local: bool = False) -> None: """Check for and download FDEV ID file updates.""" base_path = config.respath_path if local else config.app_dir_path @@ -80,28 +176,8 @@ def check_for_datafile_updates(silent: bool = False, local: bool = False) -> Non 'master_plugin_list.json': 'https://raw.githubusercontent.com/Rixxan/' 'EDMC_Plugin_Registry_Dev/refs/heads/master/master_plugin_list.json' } - update_files(base_path, files_urls, silent) - -def update_files(directory: pathlib.Path, files_urls: dict[str, str], silent: bool = False) -> None: - """Update the provided files from a remote source.""" - for filename, url in files_urls.items(): - file_path = directory / filename - local_content = read_normalized_file(file_path) or copy_bundle_file(filename, directory) - remote_content = fetch_remote_file(url) - if not remote_content: - if not silent: - logger.error(f'Failed to download {filename}! Unable to continue.') - continue - - if local_content == remote_content: - if not silent: - logger.info(f'{filename} already up to date.') - continue - - if not silent: - logger.info(f'Updating file {filename}...') - file_path.write_text(remote_content, encoding='utf-8', newline='\n') + update_files(pathlib.Path(base_path), files_urls, silent) @dataclass(slots=True) From 678ab38ad914c3fa0f8ae59948b5a098627b94c3 Mon Sep 17 00:00:00 2001 From: David Sangrey Date: Fri, 26 Dec 2025 23:19:10 -0500 Subject: [PATCH 14/32] [Minor] Remove Unneeded File Check --- EDMarketConnector.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index 50c57d90..6ee98eb8 100644 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -67,7 +67,6 @@ from config import appversion, appversion_nobuild, config, copyright from EDMCLogging import edmclogger, logger, logging from journal_lock import JournalLock, JournalLockResult -from update import check_for_fdev_updates, check_for_datafile_updates from common_utils import log_locale, SERVER_RETRY if __name__ == '__main__': # noqa: C901 @@ -2349,8 +2348,6 @@ sys.path: {sys.path}''' root.after(2, show_killswitch_poppup, root) # Start the main event loop try: - check_for_fdev_updates() - check_for_datafile_updates() root.mainloop() except KeyboardInterrupt: logger.info("Ctrl+C Detected, Attempting Clean Shutdown") From 3545f9e8d70efad9239b0f876621f36165813161 Mon Sep 17 00:00:00 2001 From: David Sangrey Date: Fri, 26 Dec 2025 23:20:17 -0500 Subject: [PATCH 15/32] [#2572] Plugin Browser First Pass --- browser.py | 11 - plugin_browser.py | 569 ++++++++++++++++++++++++++++++++++++++++++++++ prefs.py | 4 +- update.py | 6 +- 4 files changed, 576 insertions(+), 14 deletions(-) delete mode 100644 browser.py create mode 100644 plugin_browser.py diff --git a/browser.py b/browser.py deleted file mode 100644 index b911e282..00000000 --- a/browser.py +++ /dev/null @@ -1,11 +0,0 @@ -""" -browser.py - 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 plug -# from config import config, logger -# from l10n import translations as tr diff --git a/plugin_browser.py b/plugin_browser.py new file mode 100644 index 00000000..35ba7d88 --- /dev/null +++ b/plugin_browser.py @@ -0,0 +1,569 @@ +""" +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: Translations, Install Plugin, Update Plugin, notify user update 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')})" + ) + + +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) + category_label = nb.Label(plugins_frame, text="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", text="Name", command=lambda: self.__sort_plugins("pluginName") + ) + self.plugins_tree.heading("version", text="Version") + self.plugins_tree.heading( + "updated", + text="Last Updated", + command=lambda: self.__sort_plugins("pluginLastUpdate"), + ) + self.plugins_tree.heading("category", text="Category") + self.plugins_tree.heading("description", text="Description") + self.plugins_tree.heading( + "stars", text="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(plugins_frame, text="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 = [ + ("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 + ("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 = [ + ("Version", "pluginVer"), + ("Last Updated", "pluginLastUpdate"), + ("Categories", "pluginCategory", lambda v: ", ".join(v)), + ("Authors", "pluginAuthors", lambda v: ", ".join(v)), + ("License", "pluginLicense"), + ("Stars", "pluginStars"), + ("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" + + text = f"Last Tested EDMC: {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 = [ + ("Main Project Link", plugin.get("pluginMainLink")), + ("VirusTotal Link", plugin.get("pluginVT")), + ] + + 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/prefs.py b/prefs.py index 2d4405ea..408b158e 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 @@ -238,7 +239,7 @@ if sys.platform == 'win32': return '\\'.join(display_components) -class PreferencesDialog(tk.Toplevel): +class PreferencesDialog(tk.Toplevel, plugin_browser.PluginBrowserMixIn): """The EDMC preferences dialog.""" def __init__(self, parent: tk.Tk, callback: Callable | None): @@ -291,6 +292,7 @@ class PreferencesDialog(tk.Toplevel): self.__setup_output_tab(notebook) self.__setup_privacy_tab(notebook) self.__setup_plugin_tab(notebook) + self.setup_browser_tab(notebook, AutoInc()) self.__setup_plugin_tabs(notebook) # Set up the button frame diff --git a/update.py b/update.py index 15bea7db..7357775d 100644 --- a/update.py +++ b/update.py @@ -163,7 +163,8 @@ def check_for_fdev_updates(silent: bool = False, local: bool = False) -> None: 'commodity.csv': 'https://raw.githubusercontent.com/EDCD/FDevIDs/master/commodity.csv', 'rare_commodity.csv': 'https://raw.githubusercontent.com/EDCD/FDevIDs/master/rare_commodity.csv' } - + if not silent: + logger.info(f"Checking for {'local ' if local else ''}FDEVID file updates...") update_files(fdevid_dir, files_urls, silent) @@ -176,7 +177,8 @@ def check_for_datafile_updates(silent: bool = False, local: bool = False) -> Non '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) From 0e19058d29585089d2309c6e2199e8af7aa45e2d Mon Sep 17 00:00:00 2001 From: David Sangrey Date: Sat, 27 Dec 2025 18:16:57 -0500 Subject: [PATCH 16/32] Add Translations, Plugin Ver Check --- L10n/en.template | 48 +++++++++++++++++++ plugin_browser.py | 119 ++++++++++++++++++++++++++++++++-------------- prefs.py | 8 ++-- 3 files changed, 137 insertions(+), 38 deletions(-) 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/plugin_browser.py b/plugin_browser.py index 35ba7d88..8122ed82 100644 --- a/plugin_browser.py +++ b/plugin_browser.py @@ -22,14 +22,13 @@ 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 l10n import translations as tr from ttkHyperlinkLabel import HyperlinkLabel import semantic_version logger = get_main_logger() -# TODO: Translations, Install Plugin, Update Plugin, notify user update available. +# TODO in 6.2 or later: Install/Uninstall Plugin, notify user a plugin update is available. def get_plugins(): @@ -59,7 +58,7 @@ def report_plugin(plugin) -> None: # FIXME: Update to live repo. 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')})" + f"{plugin.get('pluginName')} ({plugin.get('pluginHash')[:8]})" ) @@ -85,17 +84,17 @@ class PluginBrowserMixIn: 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 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.""" @@ -135,7 +134,8 @@ class PluginBrowserMixIn: self.selected_category = tk.StringVar(value="All") # Place the dropdown on its own row (separate from the tree) - category_label = nb.Label(plugins_frame, text="Filter by Category:") + # 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) ) @@ -172,18 +172,31 @@ class PluginBrowserMixIn: ) self.plugins_tree.heading( - "name", text="Name", command=lambda: self.__sort_plugins("pluginName") + "name", + # LANG: Plugin Browser Plug Name + text=tr.tl("Name"), + command=lambda: self.__sort_plugins("pluginName"), ) - self.plugins_tree.heading("version", text="Version") + # LANG: Plugin Browser Plug Ver + self.plugins_tree.heading("version", text=tr.tl("Version")) self.plugins_tree.heading( "updated", - text="Last Updated", + text=tr.tl("Last Updated"), # LANG: Plugin Browser Last Update command=lambda: self.__sort_plugins("pluginLastUpdate"), ) - self.plugins_tree.heading("category", text="Category") - self.plugins_tree.heading("description", text="Description") self.plugins_tree.heading( - "stars", text="Stars", command=lambda: self.__sort_plugins("pluginStars") + "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) @@ -244,7 +257,11 @@ class PluginBrowserMixIn: self.plugins_tree.bind("<>", self.__on_plugin_selected) self.__populate_plugins_tree() - notebook.add(plugins_frame, text="Plugin Browser") + 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() @@ -268,11 +285,19 @@ class PluginBrowserMixIn: self._plugin_action_buttons: list[nb.Button] = [] actions = [ - ("Open Plugin Repository", open_plugin_main), + ( + # 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 - ("Report Plugin", report_plugin), + ( + # LANG: Report a Malfunctioning Plugin + tr.tl("Report Plugin"), + report_plugin, + ), ] for idx, (label, handler) in enumerate(actions): @@ -400,13 +425,32 @@ class PluginBrowserMixIn: def _build_plugin_meta(self, plugin: dict) -> list[str]: fields = [ - ("Version", "pluginVer"), - ("Last Updated", "pluginLastUpdate"), - ("Categories", "pluginCategory", lambda v: ", ".join(v)), - ("Authors", "pluginAuthors", lambda v: ", ".join(v)), - ("License", "pluginLicense"), - ("Stars", "pluginStars"), - ("Requirements", "pluginRequirements", lambda v: ", ".join(v)), + (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 = [] @@ -440,7 +484,8 @@ class PluginBrowserMixIn: else: color = "red" - text = f"Last Tested EDMC: {last_tested}" + # 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 @@ -462,8 +507,12 @@ class PluginBrowserMixIn: def _build_plugin_links(self, plugin: dict, start_row: int) -> None: links = [ - ("Main Project Link", plugin.get("pluginMainLink")), - ("VirusTotal Link", plugin.get("pluginVT")), + ( + # 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") diff --git a/prefs.py b/prefs.py index 408b158e..7ef7a8eb 100644 --- a/prefs.py +++ b/prefs.py @@ -983,9 +983,11 @@ class PreferencesDialog(tk.Toplevel, plugin_browser.PluginBrowserMixIn): 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) From 8247857ab9d570da5a6a0954b2af9be353ad862e Mon Sep 17 00:00:00 2001 From: David Sangrey Date: Sat, 27 Dec 2025 23:23:36 -0500 Subject: [PATCH 17/32] [Minor] Add Updates to Main Appwindow Init --- EDMarketConnector.py | 146 +++++++++++++++++++++---------------------- 1 file changed, 73 insertions(+), 73 deletions(-) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index 6ee98eb8..f7c8f01e 100644 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -444,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.""" @@ -455,11 +483,12 @@ 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 + 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) @@ -529,58 +558,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( @@ -611,8 +609,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() @@ -695,24 +693,22 @@ class AppWindow: theme_close = tk.Label(self.theme_menubar, image=self.theme_close) theme_close.grid(row=0, column=4, padx=2) theme.button_bind(theme_close, self.onexit, image=self.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, - lambda e: self.file_menu.tk_popup(e.widget.winfo_rootx(), - e.widget.winfo_rooty() - + e.widget.winfo_height())) - self.theme_edit_menu = tk.Label(self.theme_menubar, anchor=tk.W) - self.theme_edit_menu.grid(row=1, column=1, sticky=tk.W) - theme.button_bind(self.theme_edit_menu, - lambda e: self.edit_menu.tk_popup(e.widget.winfo_rootx(), - e.widget.winfo_rooty() - + e.widget.winfo_height())) - self.theme_help_menu = tk.Label(self.theme_menubar, anchor=tk.W) - self.theme_help_menu.grid(row=1, column=2, sticky=tk.W) - theme.button_bind(self.theme_help_menu, - lambda e: self.help_menu.tk_popup(e.widget.winfo_rootx(), - e.widget.winfo_rooty() - + e.widget.winfo_height())) + menu_map = { + 'file': self.file_menu, + 'edit': self.edit_menu, + 'help': self.help_menu, + } + self.theme_menus = {} + + for col, (name, menu) in enumerate(menu_map.items()): + lbl = tk.Label(self.theme_menubar, anchor=tk.W) + lbl.grid(row=1, column=col, sticky=tk.W, padx=self.PADX) + theme.button_bind(lbl, lambda e, m=menu: m.tk_popup(e.widget.winfo_rootx(), + e.widget.winfo_rooty() + e.widget.winfo_height())) + self.theme_menus[name] = lbl + self.theme_file_menu = self.theme_menus['file'] + self.theme_edit_menu = self.theme_menus['edit'] + self.theme_help_menu = self.theme_menus['help'] 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) @@ -768,13 +764,17 @@ 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 update_suit_text(self) -> None: """Update the suit text for current type and loadout.""" if not monitor.state['Odyssey']: From dc80dc679e9f29cf0b223af1321455294c7c675d Mon Sep 17 00:00:00 2001 From: David Sangrey Date: Sun, 28 Dec 2025 21:27:06 -0500 Subject: [PATCH 18/32] [#478] Attempt Resizing Allowance --- EDMarketConnector.py | 40 ++++++++++++++++++++++++--------------- theme.py | 45 +++++++++++++++++++++++++++++--------------- 2 files changed, 55 insertions(+), 30 deletions(-) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index f7c8f01e..7e92212c 100644 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -483,7 +483,7 @@ class AppWindow: PADX = 5 - def __init__(self, master: tk.Tk): # noqa: CCR001 # TODO - can possibly factor something out + def __init__(self, master: tk.Tk): # noqa: CCR001, C901 # TODO - can possibly factor something out query_time = config.get_int('querytime', default=0) fleetcarrier_time = config.get_int('fleetcarrierquerytime', default=0) @@ -531,9 +531,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()) @@ -675,24 +675,28 @@ class AppWindow: # Alternate title bar and menu for dark theme self.theme_menubar = tk.Frame(frame, name="alternate_menubar") self.theme_menubar.columnconfigure(2, weight=1) - theme_titlebar = tk.Label( + self.theme_titlebar = tk.Label( self.theme_menubar, name="alternate_titlebar", text=applongname, image=self.theme_icon, cursor='fleur', anchor=tk.W, compound=tk.LEFT ) - theme_titlebar.grid(columnspan=3, padx=2, sticky=tk.NSEW) + self.theme_titlebar.grid(columnspan=3, padx=2, sticky=tk.NSEW) self.drag_offset: tuple[int | None, int | None] = (None, None) - 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.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_close.grid(row=0, column=4, padx=2) - theme.button_bind(theme_close, self.onexit, image=self.theme_close) + self.theme_titlebar.bind('', self.drag_start) + self.theme_titlebar.bind('', self.drag_continue) + self.theme_titlebar.bind('', self.drag_end) + self.theme_minimize = tk.Label(self.theme_menubar, image=self.img_theme_minimize) + self.theme_minimize.grid(row=0, column=3, padx=2) + theme.button_bind(self.theme_minimize, self.oniconify, image=self.img_theme_minimize) + self.theme_close = tk.Label(self.theme_menubar, image=self.img_theme_close) + self.theme_close.grid(row=0, column=4, padx=2) + theme.button_bind(self.theme_close, self.onexit, image=self.img_theme_close) + if config.get_int('theme') != theme.THEME_TRANSPARENT: + self.theme_titlebar.grid_remove() + self.theme_minimize.grid_remove() + self.theme_close.grid_remove() menu_map = { 'file': self.file_menu, 'edit': self.edit_menu, @@ -718,7 +722,7 @@ class AppWindow: 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'): @@ -1995,6 +1999,9 @@ class AppWindow: self.w.attributes("-transparentcolor", '') self.blank_menubar.grid_remove() self.theme_menubar.grid(row=0, columnspan=2, sticky=tk.NSEW) + self.theme_titlebar.grid() + self.theme_minimize.grid() + self.theme_close.grid() def onleave(self, event=None) -> None: """Handle when our window loses focus.""" @@ -2002,6 +2009,9 @@ class AppWindow: self.w.attributes("-transparentcolor", 'grey4') self.theme_menubar.grid_remove() self.blank_menubar.grid(row=0, columnspan=2, sticky=tk.NSEW) + self.theme_titlebar.grid_remove() + self.theme_minimize.grid_remove() + self.theme_close.grid_remove() def test_logging() -> None: diff --git a/theme.py b/theme.py index 68a1a391..00dd827f 100644 --- a/theme.py +++ b/theme.py @@ -32,7 +32,7 @@ if sys.platform == "linux": if sys.platform == 'win32': import ctypes - from ctypes.wintypes import DWORD, LPCVOID, LPCWSTR + from ctypes.wintypes import DWORD, LPCVOID, LPCWSTR, BOOL import win32gui AddFontResourceEx = ctypes.windll.gdi32.AddFontResourceExW AddFontResourceEx.argtypes = [LPCWSTR, DWORD, LPCVOID] @@ -43,6 +43,17 @@ if sys.platform == 'win32': if not AddFontResourceEx(font_path, FR_PRIVATE, None): raise RuntimeError(f"Failed to load font {font_path}") + DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1 = 19 + DWMWA_USE_IMMERSIVE_DARK_MODE = 20 + + def set_dark_titlebar(hwnd: int, enable: bool = True) -> None: + """Use a Dark Windows Title Bar on Win10+.""" + value = BOOL(enable) + dwmapi = ctypes.WinDLL("dwmapi") + + for attr in (DWMWA_USE_IMMERSIVE_DARK_MODE, DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1): + dwmapi.DwmSetWindowAttribute(hwnd, attr, ctypes.byref(value), ctypes.sizeof(value)) + elif sys.platform == 'linux': # pyright: reportUnboundVariable=false XID = c_ulong # from X.h: typedef unsigned long XID @@ -393,7 +404,7 @@ class _Theme: # Apply configured theme - def apply(self, root: tk.Tk) -> None: # noqa: CCR001, C901 + def apply(self, root: tk.Tk) -> None: # noqa: CCR001 theme = config.get_int('theme') self._colors(root, theme) @@ -428,32 +439,40 @@ class _Theme: if sys.platform == 'win32': import win32con - # FIXME: Lose the "treat this like a boolean" bullshit - if theme == self.THEME_DEFAULT: - root.overrideredirect(False) - - else: - root.overrideredirect(True) - if theme == self.THEME_TRANSPARENT: + root.overrideredirect(True) root.attributes("-transparentcolor", 'grey4') else: + root.overrideredirect(False) root.attributes("-transparentcolor", '') root.withdraw() root.update_idletasks() # Size and windows styles get recalculated here + hwnd = ctypes.windll.user32.GetParent(root.winfo_id()) + set_dark_titlebar(hwnd, theme == self.THEME_DARK) hwnd = win32gui.GetParent(root.winfo_id()) win32gui.SetWindowLong(hwnd, win32con.GWL_STYLE, win32gui.GetWindowLong(hwnd, win32con.GWL_STYLE) & ~win32con.WS_MAXIMIZEBOX) # disable maximize - + win32gui.SetWindowPos(hwnd, None, 0, 0, 0, 0, + win32con.SWP_NOMOVE | win32con.SWP_NOSIZE | + win32con.SWP_NOZORDER | win32con.SWP_FRAMECHANGED + ) if theme == self.THEME_TRANSPARENT: win32gui.SetWindowLong(hwnd, win32con.GWL_EXSTYLE, win32con.WS_EX_APPWINDOW | win32con.WS_EX_LAYERED) # Add to taskbar + win32gui.SetWindowPos(hwnd, None, 0, 0, 0, 0, + win32con.SWP_NOMOVE | win32con.SWP_NOSIZE | + win32con.SWP_NOZORDER | win32con.SWP_FRAMECHANGED + ) else: win32gui.SetWindowLong(hwnd, win32con.GWL_EXSTYLE, win32con.WS_EX_APPWINDOW) # Add to taskbar + win32gui.SetWindowPos(hwnd, None, 0, 0, 0, 0, + win32con.SWP_NOMOVE | win32con.SWP_NOSIZE | + win32con.SWP_NOZORDER | win32con.SWP_FRAMECHANGED + ) root.deiconify() root.wait_visibility() # need main window to be displayed before returning @@ -480,11 +499,7 @@ class _Theme: XFlush(dpy) else: - if theme == self.THEME_DEFAULT: - root.overrideredirect(False) - - else: # Dark *or* Transparent - root.overrideredirect(True) + root.overrideredirect(theme == self.THEME_TRANSPARENT) root.deiconify() root.wait_visibility() # need main window to be displayed before returning From 958574c4999009dae3cab7f10527a37d9cf9822f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 1 Jan 2026 17:00:50 +0000 Subject: [PATCH 19/32] Bump actions/upload-artifact from 5 to 6 Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 5 to 6. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/windows-build.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml index acfe7c05..f71a6d28 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: | @@ -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-* From a194803df1053fddc534be26db4e0615251caf2c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 1 Jan 2026 17:00:57 +0000 Subject: [PATCH 20/32] Bump actions/download-artifact from 6 to 7 Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 6 to 7. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/windows-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml index acfe7c05..418dc71c 100644 --- a/.github/workflows/windows-build.yml +++ b/.github/workflows/windows-build.yml @@ -244,7 +244,7 @@ jobs: steps: - name: Download binary - uses: actions/download-artifact@v6 + uses: actions/download-artifact@v7 with: name: Built files path: ./ From 23c97d9eb6fc76ecc4120e47281f2827e19ebe9f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 1 Jan 2026 17:00:58 +0000 Subject: [PATCH 21/32] Bump types-setuptools from 80.9.0.20250822 to 80.9.0.20251223 Bumps [types-setuptools](https://github.com/typeshed-internal/stub_uploader) from 80.9.0.20250822 to 80.9.0.20251223. - [Commits](https://github.com/typeshed-internal/stub_uploader/commits) --- updated-dependencies: - dependency-name: types-setuptools dependency-version: 80.9.0.20251223 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index fba501f3..0273afa8 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -17,7 +17,7 @@ mypy==1.19.0 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 From 33be9edf65c66a851dbfa5de7b0b41bfc311eab0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 1 Jan 2026 17:01:01 +0000 Subject: [PATCH 22/32] Bump pre-commit from 4.5.0 to 4.5.1 Bumps [pre-commit](https://github.com/pre-commit/pre-commit) from 4.5.0 to 4.5.1. - [Release notes](https://github.com/pre-commit/pre-commit/releases) - [Changelog](https://github.com/pre-commit/pre-commit/blob/main/CHANGELOG.md) - [Commits](https://github.com/pre-commit/pre-commit/compare/v4.5.0...v4.5.1) --- updated-dependencies: - dependency-name: pre-commit dependency-version: 4.5.1 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index fba501f3..12b82648 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -23,7 +23,7 @@ types-setuptools==80.9.0.20250822 autopep8==2.3.2 # Git pre-commit checking -pre-commit==4.5.0 +pre-commit==4.5.1 # HTML changelogs mistune==3.1.4 From f3bc8fa42e70948caa377906af5182f310a02374 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 1 Jan 2026 17:01:05 +0000 Subject: [PATCH 23/32] Bump psutil from 7.1.3 to 7.2.1 Bumps [psutil](https://github.com/giampaolo/psutil) from 7.1.3 to 7.2.1. - [Changelog](https://github.com/giampaolo/psutil/blob/master/HISTORY.rst) - [Commits](https://github.com/giampaolo/psutil/compare/release-7.1.3...release-7.2.1) --- updated-dependencies: - dependency-name: psutil dependency-version: 7.2.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 09512b47aa29673a5aa1b2b80c18e41ddc0bd280 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 1 Jan 2026 17:01:10 +0000 Subject: [PATCH 24/32] Bump coverage[toml] from 7.12.0 to 7.13.1 Bumps [coverage[toml]](https://github.com/coveragepy/coveragepy) from 7.12.0 to 7.13.1. - [Release notes](https://github.com/coveragepy/coveragepy/releases) - [Changelog](https://github.com/coveragepy/coveragepy/blob/main/CHANGES.rst) - [Commits](https://github.com/coveragepy/coveragepy/compare/7.12.0...7.13.1) --- updated-dependencies: - dependency-name: coverage[toml] dependency-version: 7.13.1 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index fba501f3..5d60e4cd 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -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 From a054f95df11853894b0f07908ee23fd688bf4207 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 1 Jan 2026 22:58:54 +0000 Subject: [PATCH 25/32] Bump mypy from 1.19.0 to 1.19.1 Bumps [mypy](https://github.com/python/mypy) from 1.19.0 to 1.19.1. - [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md) - [Commits](https://github.com/python/mypy/compare/v1.19.0...v1.19.1) --- updated-dependencies: - dependency-name: mypy dependency-version: 1.19.1 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 7d0122b2..02e199a6 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -13,7 +13,7 @@ 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 From 2e54b1042a6ae130f7c04729a088277fcdff6f77 Mon Sep 17 00:00:00 2001 From: David Sangrey Date: Thu, 1 Jan 2026 20:38:43 -0500 Subject: [PATCH 26/32] [#478] Try a Different Tactic --- EDMarketConnector.py | 124 +++++++++++++++++++++++++++++-------------- theme.py | 45 ++++++---------- 2 files changed, 99 insertions(+), 70 deletions(-) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index 7e92212c..2edc383c 100644 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -483,7 +483,7 @@ class AppWindow: PADX = 5 - def __init__(self, master: tk.Tk): # noqa: CCR001, C901 # TODO - can possibly factor something out + def __init__(self, master: tk.Tk): # noqa: CCR001 TODO - can possibly factor something out query_time = config.get_int('querytime', default=0) fleetcarrier_time = config.get_int('fleetcarrierquerytime', default=0) @@ -675,47 +675,45 @@ class AppWindow: # Alternate title bar and menu for dark theme self.theme_menubar = tk.Frame(frame, name="alternate_menubar") self.theme_menubar.columnconfigure(2, weight=1) - self.theme_titlebar = tk.Label( + theme_titlebar = tk.Label( self.theme_menubar, name="alternate_titlebar", text=applongname, image=self.theme_icon, cursor='fleur', anchor=tk.W, compound=tk.LEFT ) - self.theme_titlebar.grid(columnspan=3, padx=2, sticky=tk.NSEW) + theme_titlebar.grid(columnspan=3, padx=2, sticky=tk.NSEW) self.drag_offset: tuple[int | None, int | None] = (None, None) - self.theme_titlebar.bind('', self.drag_start) - self.theme_titlebar.bind('', self.drag_continue) - self.theme_titlebar.bind('', self.drag_end) - self.theme_minimize = tk.Label(self.theme_menubar, image=self.img_theme_minimize) - self.theme_minimize.grid(row=0, column=3, padx=2) - theme.button_bind(self.theme_minimize, self.oniconify, image=self.img_theme_minimize) - self.theme_close = tk.Label(self.theme_menubar, image=self.img_theme_close) - self.theme_close.grid(row=0, column=4, padx=2) - theme.button_bind(self.theme_close, self.onexit, image=self.img_theme_close) - if config.get_int('theme') != theme.THEME_TRANSPARENT: - self.theme_titlebar.grid_remove() - self.theme_minimize.grid_remove() - self.theme_close.grid_remove() - menu_map = { - 'file': self.file_menu, - 'edit': self.edit_menu, - 'help': self.help_menu, - } - self.theme_menus = {} - - for col, (name, menu) in enumerate(menu_map.items()): - lbl = tk.Label(self.theme_menubar, anchor=tk.W) - lbl.grid(row=1, column=col, sticky=tk.W, padx=self.PADX) - theme.button_bind(lbl, lambda e, m=menu: m.tk_popup(e.widget.winfo_rootx(), - e.widget.winfo_rooty() + e.widget.winfo_height())) - self.theme_menus[name] = lbl - self.theme_file_menu = self.theme_menus['file'] - self.theme_edit_menu = self.theme_menus['edit'] - self.theme_help_menu = self.theme_menus['help'] + 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.img_theme_minimize) + theme_minimize.grid(row=0, column=3, padx=2) + 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.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, + lambda e: self.file_menu.tk_popup(e.widget.winfo_rootx(), + e.widget.winfo_rooty() + + e.widget.winfo_height())) + self.theme_edit_menu = tk.Label(self.theme_menubar, anchor=tk.W) + self.theme_edit_menu.grid(row=1, column=1, sticky=tk.W) + theme.button_bind(self.theme_edit_menu, + lambda e: self.edit_menu.tk_popup(e.widget.winfo_rootx(), + e.widget.winfo_rooty() + + e.widget.winfo_height())) + self.theme_help_menu = tk.Label(self.theme_menubar, anchor=tk.W) + self.theme_help_menu.grid(row=1, column=2, sticky=tk.W) + theme.button_bind(self.theme_help_menu, + lambda e: self.help_menu.tk_popup(e.widget.winfo_rootx(), + 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() @@ -779,6 +777,56 @@ class AppWindow: 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: + print(type(frame)) + 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): + dx = event.x_root - self._resize_start_x + dy = event.y_root - self._resize_start_y + self.w.geometry( + f"{self._resize_start_w + dx}x{self._resize_start_h + dy}" + ) + + def _resize_start(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']: @@ -849,6 +897,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 @@ -1999,9 +2049,6 @@ class AppWindow: self.w.attributes("-transparentcolor", '') self.blank_menubar.grid_remove() self.theme_menubar.grid(row=0, columnspan=2, sticky=tk.NSEW) - self.theme_titlebar.grid() - self.theme_minimize.grid() - self.theme_close.grid() def onleave(self, event=None) -> None: """Handle when our window loses focus.""" @@ -2009,9 +2056,6 @@ class AppWindow: self.w.attributes("-transparentcolor", 'grey4') self.theme_menubar.grid_remove() self.blank_menubar.grid(row=0, columnspan=2, sticky=tk.NSEW) - self.theme_titlebar.grid_remove() - self.theme_minimize.grid_remove() - self.theme_close.grid_remove() def test_logging() -> None: diff --git a/theme.py b/theme.py index 00dd827f..68a1a391 100644 --- a/theme.py +++ b/theme.py @@ -32,7 +32,7 @@ if sys.platform == "linux": if sys.platform == 'win32': import ctypes - from ctypes.wintypes import DWORD, LPCVOID, LPCWSTR, BOOL + from ctypes.wintypes import DWORD, LPCVOID, LPCWSTR import win32gui AddFontResourceEx = ctypes.windll.gdi32.AddFontResourceExW AddFontResourceEx.argtypes = [LPCWSTR, DWORD, LPCVOID] @@ -43,17 +43,6 @@ if sys.platform == 'win32': if not AddFontResourceEx(font_path, FR_PRIVATE, None): raise RuntimeError(f"Failed to load font {font_path}") - DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1 = 19 - DWMWA_USE_IMMERSIVE_DARK_MODE = 20 - - def set_dark_titlebar(hwnd: int, enable: bool = True) -> None: - """Use a Dark Windows Title Bar on Win10+.""" - value = BOOL(enable) - dwmapi = ctypes.WinDLL("dwmapi") - - for attr in (DWMWA_USE_IMMERSIVE_DARK_MODE, DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1): - dwmapi.DwmSetWindowAttribute(hwnd, attr, ctypes.byref(value), ctypes.sizeof(value)) - elif sys.platform == 'linux': # pyright: reportUnboundVariable=false XID = c_ulong # from X.h: typedef unsigned long XID @@ -404,7 +393,7 @@ class _Theme: # Apply configured theme - def apply(self, root: tk.Tk) -> None: # noqa: CCR001 + def apply(self, root: tk.Tk) -> None: # noqa: CCR001, C901 theme = config.get_int('theme') self._colors(root, theme) @@ -439,40 +428,32 @@ class _Theme: if sys.platform == 'win32': import win32con - if theme == self.THEME_TRANSPARENT: + # FIXME: Lose the "treat this like a boolean" bullshit + if theme == self.THEME_DEFAULT: + root.overrideredirect(False) + + else: root.overrideredirect(True) + + if theme == self.THEME_TRANSPARENT: root.attributes("-transparentcolor", 'grey4') else: - root.overrideredirect(False) root.attributes("-transparentcolor", '') root.withdraw() root.update_idletasks() # Size and windows styles get recalculated here - hwnd = ctypes.windll.user32.GetParent(root.winfo_id()) - set_dark_titlebar(hwnd, theme == self.THEME_DARK) hwnd = win32gui.GetParent(root.winfo_id()) win32gui.SetWindowLong(hwnd, win32con.GWL_STYLE, win32gui.GetWindowLong(hwnd, win32con.GWL_STYLE) & ~win32con.WS_MAXIMIZEBOX) # disable maximize - win32gui.SetWindowPos(hwnd, None, 0, 0, 0, 0, - win32con.SWP_NOMOVE | win32con.SWP_NOSIZE | - win32con.SWP_NOZORDER | win32con.SWP_FRAMECHANGED - ) + if theme == self.THEME_TRANSPARENT: win32gui.SetWindowLong(hwnd, win32con.GWL_EXSTYLE, win32con.WS_EX_APPWINDOW | win32con.WS_EX_LAYERED) # Add to taskbar - win32gui.SetWindowPos(hwnd, None, 0, 0, 0, 0, - win32con.SWP_NOMOVE | win32con.SWP_NOSIZE | - win32con.SWP_NOZORDER | win32con.SWP_FRAMECHANGED - ) else: win32gui.SetWindowLong(hwnd, win32con.GWL_EXSTYLE, win32con.WS_EX_APPWINDOW) # Add to taskbar - win32gui.SetWindowPos(hwnd, None, 0, 0, 0, 0, - win32con.SWP_NOMOVE | win32con.SWP_NOSIZE | - win32con.SWP_NOZORDER | win32con.SWP_FRAMECHANGED - ) root.deiconify() root.wait_visibility() # need main window to be displayed before returning @@ -499,7 +480,11 @@ class _Theme: XFlush(dpy) else: - root.overrideredirect(theme == self.THEME_TRANSPARENT) + if theme == self.THEME_DEFAULT: + root.overrideredirect(False) + + else: # Dark *or* Transparent + root.overrideredirect(True) root.deiconify() root.wait_visibility() # need main window to be displayed before returning From 215fc01dcb6a019b5a45cd9963d33183f8d11b0f Mon Sep 17 00:00:00 2001 From: David Sangrey Date: Thu, 1 Jan 2026 20:45:39 -0500 Subject: [PATCH 27/32] Type Hints --- EDMarketConnector.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index 2edc383c..35397ecf 100644 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -778,7 +778,6 @@ class AppWindow: self.postprefs(False) # Companion login happens in callback from monitor def _set_grabber(self, frame: tk.Frame | ttk.Frame) -> None: - print(type(frame)) if config.get_int("theme") != theme.THEME_DEFAULT: grip_size = 14 @@ -805,14 +804,14 @@ class AppWindow: self.resize_grip.place(relx=1.0, rely=1.0, anchor="se") - def _resize_drag(event): + def _resize_drag(event: tk.Event): dx = event.x_root - self._resize_start_x dy = event.y_root - self._resize_start_y self.w.geometry( f"{self._resize_start_w + dx}x{self._resize_start_h + dy}" ) - def _resize_start(event): + 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() From 726fbbdb347cb931e6f6a36b4654d8660b283a93 Mon Sep 17 00:00:00 2001 From: David Sangrey Date: Thu, 1 Jan 2026 21:13:42 -0500 Subject: [PATCH 28/32] [Minor] Don't Allow Negative Resizing --- EDMarketConnector.py | 16 +++++++-- theme.py | 77 ++++++++++++++++++++++++++++---------------- 2 files changed, 63 insertions(+), 30 deletions(-) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index 35397ecf..e3aff1d4 100644 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -807,9 +807,19 @@ class AppWindow: def _resize_drag(event: tk.Event): dx = event.x_root - self._resize_start_x dy = event.y_root - self._resize_start_y - self.w.geometry( - f"{self._resize_start_w + dx}x{self._resize_start_h + dy}" - ) + + # 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 diff --git a/theme.py b/theme.py index 68a1a391..79278b96 100644 --- a/theme.py +++ b/theme.py @@ -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): + """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): + """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: From 913a2340a7b0cdfccc5fd5640a41ee11ce8839f5 Mon Sep 17 00:00:00 2001 From: David Sangrey Date: Thu, 1 Jan 2026 22:34:59 -0500 Subject: [PATCH 29/32] Fix Merge Issues --- EDMarketConnector.py | 2 +- plug.py | 1 - theme.py | 4 ++-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index 8574b563..c1bf4b58 100644 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -485,7 +485,7 @@ class AppWindow: def __init__(self, master: tk.Tk): # noqa: CCR001 TODO - can possibly factor something out - self.early_journal_events: list[MutableMapping[str, Any]] = [] + 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 diff --git a/plug.py b/plug.py index efd97ee5..fb0c1819 100644 --- a/plug.py +++ b/plug.py @@ -183,7 +183,6 @@ class Plugin: :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 diff --git a/theme.py b/theme.py index 79278b96..41bf4b22 100644 --- a/theme.py +++ b/theme.py @@ -220,7 +220,7 @@ class _Theme: widget.bind('', lambda e: self._enter(e, image)) widget.bind('', lambda e: self._leave(e, image)) - def _enter(self, event, widget=None, image=None): + 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 @@ -249,7 +249,7 @@ class _Theme: # silently ignore unsupported image types pass - def _leave(self, event, widget=None, image=None): + 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 [] From ff72ff33171b93b5f1888cd95f823591d3a415f3 Mon Sep 17 00:00:00 2001 From: David Sangrey Date: Thu, 1 Jan 2026 23:00:23 -0500 Subject: [PATCH 30/32] [#2555] Add Safeguard Check --- theme.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/theme.py b/theme.py index 41bf4b22..a41ba528 100644 --- a/theme.py +++ b/theme.py @@ -430,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): From dfed3f1ce0d2e64b44d24fa3c1b00479449924e3 Mon Sep 17 00:00:00 2001 From: David Sangrey Date: Fri, 2 Jan 2026 22:09:50 -0500 Subject: [PATCH 31/32] [Minor] Update Inno Setup --- .github/workflows/windows-build.yml | 2 +- resources/EDMC_Installer_Config_template.txt | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml index b18b0423..7a409644 100644 --- a/.github/workflows/windows-build.yml +++ b/.github/workflows/windows-build.yml @@ -130,7 +130,7 @@ jobs: python build.py - name: InnoSetup - uses: nadeemjazmawe/inno-setup-action-cli@v6.0.5 + uses: sharktide/iscc-cli@v1.0.0 with: filepath: './EDMC_Installer_Config.iss' 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] From 8b7709aef6aa15005ff726dcdd9b1006d87b4c79 Mon Sep 17 00:00:00 2001 From: David Sangrey Date: Fri, 2 Jan 2026 22:15:04 -0500 Subject: [PATCH 32/32] Update windows-build.yml --- .github/workflows/windows-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml index 7a409644..2faeee80 100644 --- a/.github/workflows/windows-build.yml +++ b/.github/workflows/windows-build.yml @@ -130,7 +130,7 @@ jobs: python build.py - name: InnoSetup - uses: sharktide/iscc-cli@v1.0.0 + uses: sharktide/iscc-cli@1.0.0 with: filepath: './EDMC_Installer_Config.iss'