diff --git a/EDMarketConnector.py b/EDMarketConnector.py index 971fc170..be76bff1 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -10,6 +10,7 @@ import sys import webbrowser from builtins import object, str from os import chdir, environ +from os import getpid as os_getpid from os.path import dirname, isdir, join from sys import platform from time import localtime, strftime, time @@ -20,6 +21,21 @@ from constants import applongname, appname, protocolhandler_redirect # config will now cause an appname logger to be set up, so we need the # console redirect before this if __name__ == '__main__': + # 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): + # 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) + # TODO: Test: Make *sure* this redirect is working, else py2exe is going to cause an exit popup + +# After the redirect in case config does logging setup +from config import appversion, appversion_nobuild, config, copyright +from EDMCLogging import edmclogger, logger, logging + +if __name__ == '__main__': # noqa: C901 # Command-line arguments parser = argparse.ArgumentParser( prog=appname, @@ -41,74 +57,138 @@ if __name__ == '__main__': args = parser.parse_args() + if args.trace: + logger.setLevel(logging.TRACE) + edmclogger.set_channels_loglevel(logging.TRACE) + else: + edmclogger.set_channels_loglevel(logging.DEBUG) + 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. + Ensure only one copy of the app is running for the configured journal directory. :returns: True if we are the single instance, else False. """ - # TODO: Linux implementation + logger.trace('Begin...') + if platform == 'win32': - import ctypes - from ctypes.wintypes import BOOL, HWND, INT, LPARAM, LPCWSTR, LPWSTR + logger.trace('win32, using msvcrt') + # win32 doesn't have fcntl, so we have to use msvcrt + import msvcrt - 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 + logger.trace(f'journal_dir_lockfile = {journal_dir_lockfile!r}') - 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 + locked = False + try: + msvcrt.locking(journal_dir_lockfile.fileno(), msvcrt.LK_NBLCK, 4096) - COINIT_MULTITHREADED = 0 # noqa: N806,F841 - COINIT_APARTMENTTHREADED = 0x2 # noqa: N806 - COINIT_DISABLE_OLE1DDE = 0x4 # noqa: N806 - CoInitializeEx = ctypes.windll.ole32.CoInitializeEx # noqa: N806 + except Exception as e: + logger.info(f"Exception: Couldn't lock journal directory \"{journal_dir}\"" + f", assuming another process running: {e!r}") + locked = True - ShellExecute = ctypes.windll.shell32.ShellExecuteW # noqa: N806 - ShellExecute.argtypes = [HWND, LPCWSTR, LPCWSTR, LPCWSTR, LPCWSTR, INT] + if locked: + # Need to do the check for this being an edmc:// auth callback + import ctypes + from ctypes.wintypes import BOOL, HWND, INT, LPARAM, LPCWSTR, LPWSTR - 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 + 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 - return None + 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 - @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) + COINIT_MULTITHREADED = 0 # noqa: N806,F841 + COINIT_APARTMENTTHREADED = 0x2 # noqa: N806 + COINIT_DISABLE_OLE1DDE = 0x4 # noqa: N806 + CoInitializeEx = ctypes.windll.ole32.CoInitializeEx # noqa: N806 - else: - ShowWindowAsync(window_handle, SW_RESTORE) - SetForegroundWindow(window_handle) + ShellExecute = ctypes.windll.shell32.ShellExecuteW # noqa: N806 + ShellExecute.argtypes = [HWND, LPCWSTR, LPCWSTR, LPCWSTR, LPCWSTR, INT] - return False + 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 True + return None - return EnumWindows(enumwindowsproc, 0) + @ctypes.WINFUNCTYPE(BOOL, HWND, LPARAM) + def enumwindowsproc(window_handle, l_param): + """ + Determine if any window for the Application exists. + Called for each found window by EnumWindows(). + + When a match is found we check if we're being invoked as the + edmc://auth handler. If so we send the message to the existing + process/window. If not we'll raise that existing window to the + foreground. + :param window_handle: Window to check. + :param l_param: The second parameter to the EnumWindows() call. + :return: False if we found a match, else True to continue iteration + """ + # class name limited to 256 - https://msdn.microsoft.com/en-us/library/windows/desktop/ms633576 + cls = ctypes.create_unicode_buffer(257) + # This conditional is exploded to make debugging slightly easier + if GetClassName(window_handle, cls, 257): + if cls.value == 'TkTopLevel': + if window_title(window_handle) == applongname: + if 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 # Indicate window found, so stop iterating + + # Indicate that EnumWindows() needs to continue iterating + return True # Do not remove, else this function as a callback breaks + + # This performs the edmc://auth check and forward + # EnumWindows() will iterate through all open windows, calling + # enumwindwsproc() on each. When an invocation returns False it + # stops iterating. + # Ref: + EnumWindows(enumwindowsproc, 0) + + return False # Another instance is running + + else: + logger.trace('NOT win32, using fcntl') + try: + import fcntl + + except ImportError: + logger.warning("Not on win32 and we have no fcntl, can't use a file lock!" + "Allowing multiple instances!") + + try: + fcntl.flock(journal_dir_lockfile, fcntl.LOCK_EX | fcntl.LOCK_NB) + + except Exception as e: + logger.info(f"Exception: Couldn't lock journal directory \"{journal_dir}\"," + f"assuming another process running: {e!r}") + return False + + journal_dir_lockfile.write(f"Path: {journal_dir}\nPID: {os_getpid()}\n") + + logger.trace('Done') return True def already_running_popup(): @@ -134,42 +214,35 @@ if __name__ == '__main__': 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 + journal_dir: str = config.get('journaldir') or config.default_journal_dir + # This must be at top level to guarantee the file handle doesn't go out + # of scope and get cleaned up, removing the lock with it. + journal_dir_lockfile_name = join(journal_dir, 'edmc-journal-lock.txt') + try: + journal_dir_lockfile = open(journal_dir_lockfile_name, mode='w+', encoding='utf-8') - # 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) + # Linux CIFS read-only mount throws: OSError(30, 'Read-only file system') + # Linux no-write-perm directory throws: PermissionError(13, 'Permission denied') + except Exception as e: # For remote FS this could be any of a wide range of exceptions + logger.warning(f"Couldn't open \"{journal_dir_lockfile_name}\" for \"w+\"" + f" Aborting duplicate process checks: {e!r}") - # 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.") + else: + if not no_other_instance_running(): + # There's a copy already running. - # 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) + logger.info("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): - # By default py2exe tries to write log to dirname(sys.executable) which fails when installed - import tempfile + # Now that we're sure we're the only instance running we can truncate the logfile + sys.stdout.truncate() - # 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='wt', buffering=1) - # TODO: Test: Make *sure* this redirect is working, else py2exe is going to cause an exit popup - -# isort: off -from config import appversion, appversion_nobuild, config, copyright -# isort: on - -from EDMCLogging import edmclogger, logger, logging # See EDMCLogging.py docs. # isort: off @@ -195,15 +268,6 @@ import tkinter.font import tkinter.messagebox from tkinter import ttk -from ttkHyperlinkLabel import HyperlinkLabel - -if __debug__: - if platform != 'win32': - import pdb - import signal - - signal.signal(signal.SIGTERM, lambda sig, frame: pdb.Pdb().set_trace(frame)) - import commodity import companion import plug @@ -217,6 +281,7 @@ from l10n import Translations from monitor import monitor from protocol import protocolhandler from theme import theme +from ttkHyperlinkLabel import HyperlinkLabel SERVER_RETRY = 5 # retry pause for Companion servers [s] @@ -242,7 +307,7 @@ class AppWindow(object): EVENT_BUTTON = 4 EVENT_VIRTUAL = 35 - def __init__(self, master): + def __init__(self, master): # noqa: C901 self.holdofftime = config.getint('querytime') + companion.holdoff @@ -598,7 +663,7 @@ class AppWindow(object): self.status['text'] = str(e) self.cooldown() - def getandsend(self, event=None, retrying=False): + def getandsend(self, event=None, retrying=False): # noqa: C901 auto_update = not event play_sound = (auto_update or int(event.type) == self.EVENT_VIRTUAL) and not config.getint('hotkey_mute') @@ -757,7 +822,7 @@ class AppWindow(object): pass # Handle event(s) from the journal - def journal_event(self, event: str): + def journal_event(self, event: str): # noqa: C901 """ Handle a Journal event passed through event queue from monitor.py. @@ -1193,13 +1258,7 @@ Locale LC_TIME: {locale.getlocale(locale.LC_TIME)}''' # Run the app -if __name__ == "__main__": - if args.trace: - logger.setLevel(logging.TRACE) - edmclogger.set_channels_loglevel(logging.TRACE) - else: - edmclogger.set_channels_loglevel(logging.DEBUG) - +if __name__ == "__main__": # noqa C901 logger.info(f'Startup v{appversion} : Running on Python v{sys.version}') logger.debug(f'''Platform: {sys.platform} {sys.platform == "win32" and sys.getwindowsversion()} argv[0]: {sys.argv[0]} diff --git a/protocol.py b/protocol.py index 71a7ae36..886608e6 100644 --- a/protocol.py +++ b/protocol.py @@ -5,7 +5,7 @@ import threading import urllib.request, urllib.error, urllib.parse import sys -from config import config +from config import appname, config from constants import protocolhandler_redirect