diff --git a/ChangeLog.md b/ChangeLog.md index 5ff403aea..d354c6623 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -6,6 +6,29 @@ This is the master changelog for Elite Dangerous Market Connector. Entries are in the source (not distributed with the Windows installer) for the currently used version. --- +Release 5.12.1 +=== + +This release fixes a handful of bugs reported with 5.12.0, notably a widely-reported bug with EDMC CAPI Authentication. + +**Changes and Enhancements** +* Fixed a typo in the prior release notes + +**Bug Fixes** +* Fixed a bug where the EDMC System Profiler wouldn't load details properly +* Reverted a number of usages of Pathlib back to os.path for further validation testing +* Fixed a bug where EDMC would error out with a max() ValueError +* Fixed an issue where the EDMC protocol wouldn't be processed properly via prototyping + +**Plugin Developers** +* nb.Entry is deprecated, and is slated for removal in 6.0 or later. Please migrate to nb.EntryMenu +* nb.ColoredButton is deprecated, and is slated for removal in 6.0 or later. Please migrate to tk.Button +* Calling internal translations with `_()` is deprecated, and is slated for removal in 6.0 or later. Please migrate to importing `translations` and calling `translations.translate` or `translations.tl` directly +* `Translations` as the translate system singleton is deprecated, and is slated for removal in 6.0 or later. Please migrate to the `translations` singleton +* `help_open_log_folder()` is deprecated, and is slated for removal in 6.0 or later. Please migrate to open_folder() +* `update_feed` is deprecated, and is slated for removal in 6.0 or later. Please migrate to `get_update_feed()`. + + Release 5.12.0 === diff --git a/EDMC.py b/EDMC.py index ce0db95b2..6b72c7d1c 100755 --- a/EDMC.py +++ b/EDMC.py @@ -14,7 +14,6 @@ import os import queue import sys -from pathlib import Path from time import sleep, time from typing import TYPE_CHECKING, Any @@ -213,24 +212,22 @@ def main(): # noqa: C901, CCR001 # system, chances are its the current locale, and not utf-8. Otherwise if it was copied, its probably # utf8. Either way, try the system FIRST because reading something like cp1251 in UTF-8 results in garbage # but the reverse results in an exception. - json_file = Path(args.j).resolve() + json_file = os.path.abspath(args.j) try: with open(json_file) as file_handle: data = json.load(file_handle) except UnicodeDecodeError: with open(json_file, encoding='utf-8') as file_handle: data = json.load(file_handle) - file_path = Path(args.j) - modification_time = file_path.stat().st_mtime - config.set('querytime', int(modification_time)) + config.set('querytime', int(os.path.getmtime(args.j))) else: # Get state from latest Journal file logger.debug('Getting state from latest journal file') try: - monitor.currentdir = Path(config.get_str('journaldir', default=config.default_journal_dir)) + monitor.currentdir = config.get_str('journaldir', default=config.default_journal_dir) if not monitor.currentdir: - monitor.currentdir = config.default_journal_dir_path + monitor.currentdir = config.default_journal_dir logger.debug(f'logdir = "{monitor.currentdir}"') logfile = monitor.journal_newest_filename(monitor.currentdir) diff --git a/EDMCSystemProfiler.py b/EDMCSystemProfiler.py index 77212db7a..92502cc8b 100644 --- a/EDMCSystemProfiler.py +++ b/EDMCSystemProfiler.py @@ -11,7 +11,7 @@ import webbrowser import platform import sys -from os import chdir, environ +from os import chdir, environ, path import pathlib import logging from journal_lock import JournalLock @@ -19,10 +19,10 @@ if getattr(sys, "frozen", False): # Under py2exe sys.path[0] is the executable name if sys.platform == "win32": - chdir(pathlib.Path(sys.path[0]).parent) + chdir(path.dirname(sys.path[0])) # Allow executable to be invoked from any cwd - environ['TCL_LIBRARY'] = str(pathlib.Path(sys.path[0]).parent / 'lib' / 'tcl') - environ['TK_LIBRARY'] = str(pathlib.Path(sys.path[0]).parent / 'lib' / 'tk') + environ["TCL_LIBRARY"] = path.join(path.dirname(sys.path[0]), "lib", "tcl") + environ["TK_LIBRARY"] = path.join(path.dirname(sys.path[0]), "lib", "tk") else: # We still want to *try* to have CWD be where the main script is, even if @@ -44,12 +44,11 @@ def get_sys_report(config: config.AbstractConfig) -> str: plt = platform.uname() locale.setlocale(locale.LC_ALL, "") lcl = locale.getlocale() - monitor.currentdir = pathlib.Path(config.get_str( + monitor.currentdir = config.get_str( "journaldir", default=config.default_journal_dir - ) ) if not monitor.currentdir: - monitor.currentdir = config.default_journal_dir_path + monitor.currentdir = config.default_journal_dir try: logfile = monitor.journal_newest_filename(monitor.currentdir) if logfile is None: @@ -116,12 +115,12 @@ def main() -> None: root.withdraw() # Hide the window initially to calculate the dimensions try: icon_image = tk.PhotoImage( - file=cur_config.respath_path / "io.edcd.EDMarketConnector.png" + file=path.join(cur_config.respath_path, "io.edcd.EDMarketConnector.png") ) root.iconphoto(True, icon_image) except tk.TclError: - root.iconbitmap(cur_config.respath_path / "EDMarketConnector.ico") + root.iconbitmap(path.join(cur_config.respath_path, "EDMarketConnector.ico")) sys_report = get_sys_report(cur_config) diff --git a/config/__init__.py b/config/__init__.py index ec5a345c2..18d3d941d 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -52,7 +52,7 @@ # # Major.Minor.Patch(-prerelease)(+buildmetadata) # NB: Do *not* import this, use the functions appversion() and appversion_nobuild() -_static_appversion = '5.12.0' +_static_appversion = '5.12.1' _cached_version: semantic_version.Version | None = None copyright = '© 2015-2019 Jonathan Harris, 2020-2024 EDCD' diff --git a/monitor.py b/monitor.py index ce6b60759..e19d6ad56 100644 --- a/monitor.py +++ b/monitor.py @@ -8,7 +8,7 @@ from __future__ import annotations import json -from pathlib import Path +import pathlib import queue import re import sys @@ -16,6 +16,7 @@ from calendar import timegm from collections import defaultdict from os import SEEK_END, SEEK_SET, listdir +from os.path import basename, expanduser, getctime, isdir, join from time import gmtime, localtime, mktime, sleep, strftime, strptime, time from typing import TYPE_CHECKING, Any, BinaryIO, MutableMapping import psutil @@ -67,7 +68,7 @@ def __init__(self) -> None: # TODO(A_D): A bunch of these should be switched to default values (eg '' for strings) and no longer be Optional FileSystemEventHandler.__init__(self) # futureproofing - not need for current version of watchdog self.root: 'tkinter.Tk' = None # type: ignore # Don't use Optional[] - mypy thinks no methods - self.currentdir: Path | None = None # The actual logdir that we're monitoring + self.currentdir: str | None = None # The actual logdir that we're monitoring self.logfile: str | None = None self.observer: BaseObserver | None = None self.observed = None # a watchdog ObservedWatch, or None if polling @@ -190,9 +191,9 @@ def start(self, root: 'tkinter.Tk') -> bool: # noqa: CCR001 if journal_dir == '' or journal_dir is None: journal_dir = config.default_journal_dir - logdir = Path(journal_dir).expanduser() + logdir = expanduser(journal_dir) - if not logdir or not Path.is_dir(logdir): + if not logdir or not isdir(logdir): logger.error(f'Journal Directory is invalid: "{logdir}"') self.stop() return False @@ -265,10 +266,9 @@ def journal_newest_filename(self, journals_dir) -> str | None: # Odyssey Update 11 has, e.g. Journal.2022-03-15T152503.01.log # Horizons Update 11 equivalent: Journal.220315152335.01.log # So we can no longer use a naive sort. - journals_dir_path = Path(journals_dir) - journal_files = (journals_dir_path / Path(x) for x in journal_files) - latest_file = max(journal_files, key=lambda f: Path(f).stat().st_ctime) - return str(latest_file) + journals_dir_path = pathlib.Path(journals_dir) + journal_files = (journals_dir_path / pathlib.Path(x) for x in journal_files) + return str(max(journal_files, key=getctime)) return None @@ -337,7 +337,7 @@ def running(self) -> bool: def on_created(self, event: 'FileSystemEvent') -> None: """Watchdog callback when, e.g. client (re)started.""" - if not event.is_directory and self._RE_LOGFILE.search(Path(event.src_path).name): + if not event.is_directory and self._RE_LOGFILE.search(basename(event.src_path)): self.logfile = event.src_path @@ -1076,7 +1076,7 @@ def parse_entry(self, line: bytes) -> MutableMapping[str, Any]: # noqa: C901, C self.state['Cargo'] = defaultdict(int) # From 3.3 full Cargo event (after the first one) is written to a separate file if 'Inventory' not in entry: - with open(self.currentdir / 'Cargo.json', 'rb') as h: # type: ignore + with open(join(self.currentdir, 'Cargo.json'), 'rb') as h: # type: ignore entry = json.load(h) self.state['CargoJSON'] = entry @@ -1103,7 +1103,7 @@ def parse_entry(self, line: bytes) -> MutableMapping[str, Any]: # noqa: C901, C # Always attempt loading of this, but if it fails we'll hope this was # a startup/boarding version and thus `entry` contains # the data anyway. - currentdir_path = Path(str(self.currentdir)) + currentdir_path = pathlib.Path(str(self.currentdir)) shiplocker_filename = currentdir_path / 'ShipLocker.json' shiplocker_max_attempts = 5 shiplocker_fail_sleep = 0.01 @@ -1172,7 +1172,7 @@ def parse_entry(self, line: bytes) -> MutableMapping[str, Any]: # noqa: C901, C # TODO: v31 doc says this is`backpack.json` ... but Howard Chalkley # said it's `Backpack.json` - backpack_file = Path(str(self.currentdir)) / 'Backpack.json' + backpack_file = pathlib.Path(str(self.currentdir)) / 'Backpack.json' backpack_data = None if not backpack_file.exists(): @@ -1548,7 +1548,7 @@ def parse_entry(self, line: bytes) -> MutableMapping[str, Any]: # noqa: C901, C entry = fcmaterials elif event_type == 'moduleinfo': - with open(self.currentdir / 'ModulesInfo.json', 'rb') as mf: # type: ignore + with open(join(self.currentdir, 'ModulesInfo.json'), 'rb') as mf: # type: ignore try: entry = json.load(mf) @@ -2259,14 +2259,14 @@ def export_ship(self, filename=None) -> None: # noqa: C901, CCR001 oldfiles = sorted((x for x in listdir(config.get_str('outdir')) if regexp.match(x))) if oldfiles: try: - with open(config.get_str('outdir') / Path(oldfiles[-1]), encoding='utf-8') as h: + with open(join(config.get_str('outdir'), oldfiles[-1]), encoding='utf-8') as h: if h.read() == string: return # same as last time - don't write except UnicodeError: logger.exception("UnicodeError reading old ship loadout with utf-8 encoding, trying without...") try: - with open(config.get_str('outdir') / Path(oldfiles[-1])) as h: + with open(join(config.get_str('outdir'), oldfiles[-1])) as h: if h.read() == string: return # same as last time - don't write @@ -2285,7 +2285,7 @@ def export_ship(self, filename=None) -> None: # noqa: C901, CCR001 # Write ts = strftime('%Y-%m-%dT%H.%M.%S', localtime(time())) - filename = config.get_str('outdir') / Path(f'{ship}.{ts}.txt') + filename = join(config.get_str('outdir'), f'{ship}.{ts}.txt') try: with open(filename, 'wt', encoding='utf-8') as h: @@ -2372,7 +2372,7 @@ def _parse_navroute_file(self) -> dict[str, Any] | None: try: - with open(self.currentdir / 'NavRoute.json') as f: + with open(join(self.currentdir, 'NavRoute.json')) as f: raw = f.read() except Exception as e: @@ -2398,7 +2398,7 @@ def _parse_fcmaterials_file(self) -> dict[str, Any] | None: try: - with open(self.currentdir / 'FCMaterials.json') as f: + with open(join(self.currentdir, 'FCMaterials.json')) as f: raw = f.read() except Exception as e: diff --git a/prefs.py b/prefs.py index 29e1e7b13..95f29e7fc 100644 --- a/prefs.py +++ b/prefs.py @@ -4,7 +4,7 @@ import contextlib import logging -from os.path import expandvars +from os.path import expandvars, join, normpath from pathlib import Path import subprocess import sys @@ -1100,7 +1100,7 @@ def displaypath(self, pathvar: tk.StringVar, entryfield: tk.Entry) -> None: if sys.platform == 'win32': start = len(config.home.split('\\')) if pathvar.get().lower().startswith(config.home.lower()) else 0 display = [] - components = Path(pathvar.get()).resolve().parts + components = normpath(pathvar.get()).split('\\') buf = ctypes.create_unicode_buffer(MAX_PATH) pidsRes = ctypes.c_int() # noqa: N806 # Windows convention for i in range(start, len(components)): @@ -1253,7 +1253,7 @@ def apply(self) -> None: # noqa: CCR001 config.set( 'outdir', - str(config.home_path / self.outdir.get()[2:]) if self.outdir.get().startswith('~') else self.outdir.get() + join(config.home_path, self.outdir.get()[2:]) if self.outdir.get().startswith('~') else self.outdir.get() ) logdir = self.logdir.get() @@ -1296,8 +1296,8 @@ def apply(self) -> None: # noqa: CCR001 if self.plugdir.get() != config.get('plugin_dir'): config.set( 'plugin_dir', - str(Path(config.home_path, self.plugdir.get()[2:])) if self.plugdir.get().startswith('~') else - str(Path(self.plugdir.get())) + join(config.home_path, self.plugdir.get()[2:]) if self.plugdir.get().startswith( + '~') else self.plugdir.get() ) self.req_restart = True diff --git a/protocol.py b/protocol.py index a0f02f158..01be09de8 100644 --- a/protocol.py +++ b/protocol.py @@ -69,16 +69,14 @@ def event(self, url: str) -> None: # This could be false if you use auth_force_edmc_protocol, but then you get to keep the pieces assert sys.platform == 'win32' # spell-checker: words HBRUSH HICON WPARAM wstring WNDCLASS HMENU HGLOBAL - from ctypes import ( - windll, WINFUNCTYPE, Structure, byref, c_long, c_void_p, create_unicode_buffer, wstring_at + from ctypes import ( # type: ignore + windll, POINTER, WINFUNCTYPE, Structure, byref, c_long, c_void_p, create_unicode_buffer, wstring_at ) from ctypes.wintypes import ( - ATOM, HBRUSH, HICON, HINSTANCE, HWND, INT, LPARAM, LPCWSTR, LPWSTR, + ATOM, BOOL, DWORD, HBRUSH, HGLOBAL, HICON, HINSTANCE, HMENU, HWND, INT, LPARAM, LPCWSTR, LPMSG, LPVOID, LPWSTR, MSG, UINT, WPARAM ) import win32gui - import win32con - import win32api class WNDCLASS(Structure): """ @@ -101,8 +99,33 @@ class WNDCLASS(Structure): ('lpszClassName', LPCWSTR) ] - TranslateMessage = windll.user32.TranslateMessage + CW_USEDEFAULT = 0x80000000 + + CreateWindowExW = windll.user32.CreateWindowExW + CreateWindowExW.argtypes = [DWORD, LPCWSTR, LPCWSTR, DWORD, INT, INT, INT, INT, HWND, HMENU, HINSTANCE, LPVOID] + CreateWindowExW.restype = HWND + RegisterClassW = windll.user32.RegisterClassW + RegisterClassW.argtypes = [POINTER(WNDCLASS)] + + GetParent = windll.user32.GetParent + SetForegroundWindow = windll.user32.SetForegroundWindow + # + # NB: Despite 'BOOL' return type, it *can* be >0, 0 or -1, so is actually + # c_long + prototype = WINFUNCTYPE(c_long, LPMSG, HWND, UINT, UINT) + paramflags = (1, "lpMsg"), (1, "hWnd"), (1, "wMsgFilterMin"), (1, "wMsgFilterMax") + GetMessageW = prototype(("GetMessageW", windll.user32), paramflags) + + TranslateMessage = windll.user32.TranslateMessage + DispatchMessageW = windll.user32.DispatchMessageW + PostThreadMessageW = windll.user32.PostThreadMessageW + SendMessageW = windll.user32.SendMessageW + SendMessageW.argtypes = [HWND, UINT, WPARAM, LPARAM] + PostMessageW = windll.user32.PostMessageW + PostMessageW.argtypes = [HWND, UINT, WPARAM, LPARAM] + + WM_QUIT = 0x0012 # https://docs.microsoft.com/en-us/windows/win32/dataxchg/wm-dde-initiate WM_DDE_INITIATE = 0x03E0 WM_DDE_TERMINATE = 0x03E1 @@ -118,6 +141,12 @@ class WNDCLASS(Structure): GlobalGetAtomNameW = windll.kernel32.GlobalGetAtomNameW GlobalGetAtomNameW.argtypes = [ATOM, LPWSTR, INT] GlobalGetAtomNameW.restype = UINT + GlobalLock = windll.kernel32.GlobalLock + GlobalLock.argtypes = [HGLOBAL] + GlobalLock.restype = LPVOID + GlobalUnlock = windll.kernel32.GlobalUnlock + GlobalUnlock.argtypes = [HGLOBAL] + GlobalUnlock.restype = BOOL # Windows Message handler stuff (IPC) # https://docs.microsoft.com/en-us/previous-versions/windows/desktop/legacy/ms633573(v=vs.85) @@ -160,7 +189,7 @@ def WndProc(hwnd: HWND, message: UINT, wParam: WPARAM, lParam: LPARAM) -> c_long if target_is_valid and topic_is_valid: # if everything is happy, send an acknowledgement of the DDE request - win32gui.SendMessage( + SendMessageW( wParam, WM_DDE_ACK, hwnd, PackDDElParam(WM_DDE_ACK, GlobalAddAtomW(appname), GlobalAddAtomW('System')) ) @@ -193,7 +222,7 @@ def close(self) -> None: thread = self.thread if thread: self.thread = None - win32api.PostThreadMessage(thread.ident, win32con.WM_QUIT, 0, 0) + PostThreadMessageW(thread.ident, WM_QUIT, 0, 0) thread.join() # Wait for it to quit def worker(self) -> None: @@ -203,25 +232,24 @@ def worker(self) -> None: wndclass.lpfnWndProc = WndProc wndclass.cbClsExtra = 0 wndclass.cbWndExtra = 0 - wndclass.hInstance = win32gui.GetModuleHandle(0) + wndclass.hInstance = windll.kernel32.GetModuleHandleW(0) wndclass.hIcon = None wndclass.hCursor = None wndclass.hbrBackground = None wndclass.lpszMenuName = None wndclass.lpszClassName = 'DDEServer' - if not win32gui.RegisterClass(byref(wndclass)): + if not RegisterClassW(byref(wndclass)): print('Failed to register Dynamic Data Exchange for cAPI') return # https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-createwindowexw - hwnd = win32gui.CreateWindowEx( + hwnd = CreateWindowExW( 0, # dwExStyle wndclass.lpszClassName, # lpClassName "DDE Server", # lpWindowName 0, # dwStyle - # X, Y, nWidth, nHeight - win32con.CW_USEDEFAULT, win32con.CW_USEDEFAULT, win32con.CW_USEDEFAULT, win32con.CW_USEDEFAULT, + CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, # X, Y, nWidth, nHeight self.master.winfo_id(), # hWndParent # Don't use HWND_MESSAGE since the window won't get DDE broadcasts None, # hMenu wndclass.hInstance, # hInstance @@ -241,13 +269,13 @@ def worker(self) -> None: # # But it does actually work. Either getting a non-0 value and # entering the loop, or getting 0 and exiting it. - while win32gui.GetMessage(byref(msg), None, 0, 0) != 0: + while GetMessageW(byref(msg), None, 0, 0) != 0: logger.trace_if('frontier-auth.windows', f'DDE message of type: {msg.message}') if msg.message == WM_DDE_EXECUTE: # GlobalLock does some sort of "please dont move this?" # https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globallock - args = wstring_at(win32gui.GlobalLock(msg.lParam)).strip() - win32gui.GlobalUnlock(msg.lParam) # Unlocks the GlobalLock-ed object + args = wstring_at(GlobalLock(msg.lParam)).strip() + GlobalUnlock(msg.lParam) # Unlocks the GlobalLock-ed object if args.lower().startswith('open("') and args.endswith('")'): logger.trace_if('frontier-auth.windows', f'args are: {args}') @@ -256,20 +284,20 @@ def worker(self) -> None: logger.debug(f'Message starts with {self.redirect}') self.event(url) - win32gui.SetForegroundWindow(win32gui.GetParent(self.master.winfo_id())) # raise app window + SetForegroundWindow(GetParent(self.master.winfo_id())) # raise app window # Send back a WM_DDE_ACK. this is _required_ with WM_DDE_EXECUTE - win32gui.PostMessage(msg.wParam, WM_DDE_ACK, hwnd, PackDDElParam(WM_DDE_ACK, 0x80, msg.lParam)) + PostMessageW(msg.wParam, WM_DDE_ACK, hwnd, PackDDElParam(WM_DDE_ACK, 0x80, msg.lParam)) else: # Send back a WM_DDE_ACK. this is _required_ with WM_DDE_EXECUTE - win32gui.PostMessage(msg.wParam, WM_DDE_ACK, hwnd, PackDDElParam(WM_DDE_ACK, 0, msg.lParam)) + PostMessageW(msg.wParam, WM_DDE_ACK, hwnd, PackDDElParam(WM_DDE_ACK, 0, msg.lParam)) elif msg.message == WM_DDE_TERMINATE: - win32gui.PostMessage(msg.wParam, WM_DDE_TERMINATE, hwnd, 0) + PostMessageW(msg.wParam, WM_DDE_TERMINATE, hwnd, 0) else: TranslateMessage(byref(msg)) # "Translates virtual key messages into character messages" ??? - win32gui.DispatchMessage(byref(msg)) + DispatchMessageW(byref(msg)) else: # Linux / Run from source @@ -348,7 +376,7 @@ def do_GET(self) -> None: # noqa: N802 # Required to override if self.parse(): self.send_header('Content-Type', 'text/html') self.end_headers() - self.wfile.write(self._generate_auth_response().encode('utf-8')) + self.wfile.write(self._generate_auth_response().encode()) else: self.send_response(404) self.end_headers()