diff --git a/EDMCLogging.py b/EDMCLogging.py index 246ee78d..308e85fc 100644 --- a/EDMCLogging.py +++ b/EDMCLogging.py @@ -287,11 +287,16 @@ class EDMCContextFilter(logging.Filter): frame_info = inspect.getframeinfo(frame) args, _, _, value_dict = inspect.getargvalues(frame) if len(args) and args[0] in ('self', 'cls'): - frame_class = value_dict[args[0]] + frame_class: 'object' = value_dict[args[0]] if frame_class: + # See https://en.wikipedia.org/wiki/Name_mangling#Python for how name mangling works. + name = frame_info.function + if name.startswith("__") and not name.endswith("__"): + name = f'_{frame_class.__class__.__name__}{frame_info.function}' + # Find __qualname__ of the caller - fn = getattr(frame_class, frame_info.function) + fn = getattr(frame_class, name, None) if fn and fn.__qualname__: caller_qualname = fn.__qualname__ diff --git a/EDMarketConnector.py b/EDMarketConnector.py index 90ff631f..b1b2a55f 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -17,8 +17,117 @@ from typing import TYPE_CHECKING from config import applongname, appname, appversion, appversion_nobuild, config, copyright -# TODO: Test: Make *sure* this redirect is working, else py2exe is going to cause an exit popup if __name__ == "__main__": + def no_other_instance_running() -> bool: # noqa: CCR001 + """ + Ensure only one copy of the app is running under this user account. + + OSX does this automatically. + + :returns: True if we are the single instance, else False. + """ + # TODO: Linux implementation + if platform == 'win32': + import ctypes + from ctypes.wintypes import BOOL, HWND, INT, LPARAM, LPCWSTR, LPWSTR + + EnumWindows = ctypes.windll.user32.EnumWindows # noqa: N806 + GetClassName = ctypes.windll.user32.GetClassNameW # noqa: N806 + GetClassName.argtypes = [HWND, LPWSTR, ctypes.c_int] + GetWindowText = ctypes.windll.user32.GetWindowTextW # noqa: N806 + GetWindowText.argtypes = [HWND, LPWSTR, ctypes.c_int] + GetWindowTextLength = ctypes.windll.user32.GetWindowTextLengthW # noqa: N806 + GetProcessHandleFromHwnd = ctypes.windll.oleacc.GetProcessHandleFromHwnd # noqa: N806 + + SW_RESTORE = 9 # noqa: N806 + SetForegroundWindow = ctypes.windll.user32.SetForegroundWindow # noqa: N806 + ShowWindow = ctypes.windll.user32.ShowWindow # noqa: N806 + ShowWindowAsync = ctypes.windll.user32.ShowWindowAsync # noqa: N806 + + COINIT_MULTITHREADED = 0 # noqa: N806,F841 + COINIT_APARTMENTTHREADED = 0x2 # noqa: N806 + COINIT_DISABLE_OLE1DDE = 0x4 # noqa: N806 + CoInitializeEx = ctypes.windll.ole32.CoInitializeEx # noqa: N806 + + ShellExecute = ctypes.windll.shell32.ShellExecuteW # noqa: N806 + ShellExecute.argtypes = [HWND, LPCWSTR, LPCWSTR, LPCWSTR, LPCWSTR, INT] + + def window_title(h): + if h: + text_length = GetWindowTextLength(h) + 1 + buf = ctypes.create_unicode_buffer(text_length) + if GetWindowText(h, buf, text_length): + return buf.value + + return None + + @ctypes.WINFUNCTYPE(BOOL, HWND, LPARAM) + def enumwindowsproc(window_handle, l_param): + # class name limited to 256 - https://msdn.microsoft.com/en-us/library/windows/desktop/ms633576 + cls = ctypes.create_unicode_buffer(257) + if GetClassName(window_handle, cls, 257) \ + and cls.value == 'TkTopLevel' \ + and window_title(window_handle) == applongname \ + and GetProcessHandleFromHwnd(window_handle): + # If GetProcessHandleFromHwnd succeeds then the app is already running as this user + if len(sys.argv) > 1 and sys.argv[1].startswith(protocolhandler.redirect): + CoInitializeEx(0, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE) + # Wait for it to be responsive to avoid ShellExecute recursing + ShowWindow(window_handle, SW_RESTORE) + ShellExecute(0, None, sys.argv[1], None, None, SW_RESTORE) + + else: + ShowWindowAsync(window_handle, SW_RESTORE) + SetForegroundWindow(window_handle) + + return False + + return True + + return EnumWindows(enumwindowsproc, 0) + + return True + + def already_running_popup(): + """Create the "already running" popup.""" + import tkinter as tk + from tkinter import ttk + + root = tk.Tk(className=appname.lower()) + + frame = tk.Frame(root) + frame.grid(row=1, column=0, sticky=tk.NSEW) + + label = tk.Label(frame) + label['text'] = 'An EDMarketConnector.exe process was already running, exiting.' + label.grid(row=1, column=0, sticky=tk.NSEW) + + button = ttk.Button(frame, text='OK', command=lambda: sys.exit(0)) + button.grid(row=2, column=0, sticky=tk.S) + + root.mainloop() + + if not no_other_instance_running(): + # There's a copy already running. We want to inform the user by + # **appending** to the log file, not truncating it. + if getattr(sys, 'frozen', False): + # By default py2exe tries to write log to dirname(sys.executable) which fails when installed + import tempfile + + # unbuffered not allowed for text in python3, so use `1 for line buffering + sys.stdout = sys.stderr = open(join(tempfile.gettempdir(), f'{appname}.log'), mode='a', buffering=1) + + # Logging isn't set up yet. + # We'll keep this print, but it will be over-written by any subsequent + # write by the already-running process. + print("An EDMarketConnector.exe process was already running, exiting.") + + # To be sure the user knows, we need a popup + already_running_popup() + # If the user closes the popup with the 'X', not the 'OK' button we'll + # reach here. + sys.exit(0) + # Keep this as the very first code run to be as sure as possible of no # output until after this redirect is done, if needed. if getattr(sys, 'frozen', False): @@ -36,6 +145,9 @@ if TYPE_CHECKING: from logging import trace, TRACE # type: ignore # noqa: F401 # isort: on + def _(x: str) -> str: + """Fake the l10n translation functions for typing.""" + return x if getattr(sys, 'frozen', False): # Under py2exe sys.path[0] is the executable name @@ -947,18 +1059,43 @@ class AppWindow(object): if platform != 'darwin' or self.w.winfo_rooty() > 0: x, y = self.w.geometry().split('+')[1:3] # e.g. '212x170+2881+1267' config.set('geometry', f'+{x}+{y}') - self.w.withdraw() # Following items can take a few seconds, so hide the main window while they happen + + # Let the user know we're shutting down. + self.status['text'] = 'Shutting down...' + self.w.update_idletasks() + logger.info('Starting shutdown procedures...') + + logger.info('Closing protocol handler...') protocolhandler.close() + + logger.info('Unregistering hotkey manager...') hotkeymgr.unregister() + + logger.info('Closing dashboard...') dashboard.close() + + logger.info('Closing journal monitor...') monitor.close() + + logger.info('Notifying plugins to stop...') plug.notify_stop() + + logger.info('Closing update checker...') self.updater.close() + + logger.info('Closing Frontier CAPI sessions...') companion.session.close() + + logger.info('Closing config...') config.close() + + logger.info('Destroying app window...') self.w.destroy() - def drag_start(self, event): + logger.info('Done.') + + def drag_start(self, event) -> None: + """Initiate dragging the window.""" self.drag_offset = (event.x_root - self.w.winfo_rootx(), event.y_root - self.w.winfo_rooty()) def drag_continue(self, event): @@ -1165,6 +1302,10 @@ sys.path: {sys.path}''' def __init__(self): logger.debug('A call from A.B.__init__') + self.__test() + + def __test(self): + logger.debug("A call from A.B.__test") # abinit = A.B() diff --git a/docs/Releasing.md b/docs/Releasing.md index b340e256..fb0623a3 100644 --- a/docs/Releasing.md +++ b/docs/Releasing.md @@ -33,29 +33,16 @@ You will need several pieces of software installed, or the files from their [v3.7.9](https://www.python.org/downloads/release/python-379/) is the most recently tested version. You need the `Windows x86 executable installer` file, for the 32-bit version. -1. [py2exe](https://github.com/albertosottile/py2exe): - 1. Install the python module. There are two options here. - 1. You can use the latest release version [0.9.3.2](https://github.com/albertosottile/py2exe/releases/tag/v0.9.3.2) - and the current Marginal 'python3' branch as-is. This contains a - small hack in `setup.py` to ensure `sqlite3.dll` is packaged. - - pip install py2exe-0.9.3.2-cp37-none-win32.whl - 1. Or you can use a pre-release version, [0.9.4.0](https://bintray.com/alby128/py2exe/download_file?file_path=py2exe-0.9.4.0-cp37-none-win32.whl), see [this py2exe issue](https://github.com/albertosottile/py2exe/issues/23#issuecomment-541359225), - which packages that DLL file correctly. - - pip install py2exe-0.9.4.0-cp37-none-win32.whl - You can then edit out the following line from `setup.py`, but it - does no harm: - - %s/DLLs/sqlite3.dll' % (sys.base_prefix), +1. [py2exe](https://github.com/albertosottile/py2exe) - Now available via PyPi, + so will be picked up with the `pip install` below. Latest tested as per + `requirements-dev.txt`. 1. You'll now need to 'pip install' several python modules. 1. Ensure you have `pip` installed. If needs be see [Installing pip](https://pip.pypa.io/en/stable/installing/) 1. The easiest way is to utilise the `requirements-dev.txt` file: - `python -m pip install -r requirements-dev.txt`. This will install all - dependencies plus anything required for development *other than py2exe, see - above*. + `python -m pip install --user -r requirements-dev.txt`. This will install + all dependencies plus anything required for development. 1. Else check the contents of both `requirements.txt` and `requirements-dev.txt`, and ensure the modules listed there are installed as per the version requirements. diff --git a/requirements-dev.txt b/requirements-dev.txt index 7f756495..b50ae664 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -20,9 +20,8 @@ autopep8==1.5.4 grip>=4.5.2 # Packaging -# This isn't available via 'pip install', so has to be commented out in order for -# GitHub Action Workflows to not error out -#py2exe==0.9.3.2 +# We only need py2exe on windows. +py2exe==0.10.0.2; sys_platform == 'win32' # All of the normal requirements -r requirements.txt