1
0
mirror of https://github.com/EDCD/EDMarketConnector.git synced 2025-06-08 19:32:15 +03:00

Port in the journals_dir locking and other changes

* Use a lock file in the journals_dir location to prevent more than one
  instance running against the same journals.  We no longer check just
  for a Windows handle.  So this is more correct on win32 *and* is now a
  thing on all other platforms.
* Adds `--suppress-dupe-process-popup` CL arg to suppress "we're a
  dupe!" popup to aid those using batch files to launch EDMC alongside
  the game.
* Two minor fixups of typos in PLUGINS.md.
* Misc noqa comments and other flake8 fixups.  We're now only missing
  type annotations in EDMarketConnector.py.

# Conflicts:
#	EDMarketConnector.py
This commit is contained in:
Athanasius 2021-03-22 14:21:29 +00:00
parent 8b70ae7027
commit 409e851840

View File

@ -11,126 +11,17 @@ import sys
import webbrowser import webbrowser
from builtins import object, str from builtins import object, str
from os import chdir, environ from os import chdir, environ
from os import getpid as os_getpid
from os.path import dirname, isdir, join from os.path import dirname, isdir, join
from sys import platform from sys import platform
from time import localtime, strftime, time from time import localtime, strftime, time
from typing import TYPE_CHECKING, Any, Mapping, Optional, Tuple, cast from typing import TYPE_CHECKING, Any, Mapping, Optional, Tuple
from constants import applongname, appname, protocolhandler_redirect from constants import applongname, appname, protocolhandler_redirect
# config will now cause an appname logger to be set up, so we need the # config will now cause an appname logger to be set up, so we need the
# console redirect before this # console redirect before this
if __name__ == '__main__': 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 # 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. # output until after this redirect is done, if needed.
if getattr(sys, 'frozen', False): if getattr(sys, 'frozen', False):
@ -141,16 +32,14 @@ if __name__ == '__main__':
sys.stdout = sys.stderr = open(join(tempfile.gettempdir(), f'{appname}.log'), mode='wt', buffering=1) 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 # TODO: Test: Make *sure* this redirect is working, else py2exe is going to cause an exit popup
# These need to be after the stdout/err redirect because they will cause
# logging to be set up.
# isort: off # isort: off
import killswitch # Will cause a logging import/startup so needs to be after the redirect import killswitch
from config import appversion, appversion_nobuild, config, copyright from config import appversion, appversion_nobuild, config, copyright
# isort: on # isort: on
# After the redirect in case config does logging setup
from config import appversion, appversion_nobuild, config, copyright
from EDMCLogging import edmclogger, logger, logging from EDMCLogging import edmclogger, logger, logging
from journal_lock import JournalLock, JournalLockResult
if __name__ == '__main__': # noqa: C901 if __name__ == '__main__': # noqa: C901
# Command-line arguments # Command-line arguments
@ -162,26 +51,23 @@ if __name__ == '__main__': # noqa: C901
"such as EDSM, Inara.cz and EDDB." "such as EDSM, Inara.cz and EDDB."
) )
parser.add_argument('--trace', parser.add_argument(
'--trace',
help='Set the Debug logging loglevel to TRACE', help='Set the Debug logging loglevel to TRACE',
action='store_true', action='store_true',
) )
parser.add_argument(
'--reset-ui',
help='reset UI theme and transparency to defaults',
action='store_true'
)
parser.add_argument('--suppress-dupe-process-popup', parser.add_argument('--suppress-dupe-process-popup',
help='Suppress the popup from when the application detects another instance already running', help='Suppress the popup from when the application detects another instance already running',
action='store_true' action='store_true'
) )
parser.add_argument('--force-localserver-for-auth',
help='Force EDMC to use a localhost webserver for Frontier Auth callback',
action='store_true'
)
parser.add_argument('edmc',
help='Callback from Frontier Auth',
nargs='*'
)
args = parser.parse_args() args = parser.parse_args()
if args.trace: if args.trace:
@ -190,18 +76,32 @@ if __name__ == '__main__': # noqa: C901
else: else:
edmclogger.set_channels_loglevel(logging.DEBUG) edmclogger.set_channels_loglevel(logging.DEBUG)
if args.force_localserver_for_auth: def no_other_instance_running() -> bool: # noqa: CCR001
config.set_auth_force_localserver() """
Ensure only one copy of the app is running for the configured journal directory.
def handle_edmc_callback_or_foregrounding(): # noqa: CCR001 :returns: True if we are the single instance, else False.
"""Handle any edmc:// auth callback, else foreground existing window.""" """
logger.trace('Begin...') logger.trace('Begin...')
if platform == 'win32': if platform == 'win32':
logger.trace('win32, using msvcrt')
# win32 doesn't have fcntl, so we have to use msvcrt
import msvcrt
# If *this* instance hasn't locked, then another already has and we logger.trace(f'journal_dir_lockfile = {journal_dir_lockfile!r}')
# now need to do the edmc:// checks for auth callback
if locked != JournalLockResult.LOCKED: locked = False
try:
msvcrt.locking(journal_dir_lockfile.fileno(), msvcrt.LK_NBLCK, 4096)
except Exception as e:
logger.info(f"Exception: Couldn't lock journal directory \"{journal_dir}\""
f", assuming another process running: {e!r}")
locked = True
if locked:
# Need to do the check for this being an edmc:// auth callback
import ctypes import ctypes
from ctypes.wintypes import BOOL, HWND, INT, LPARAM, LPCWSTR, LPWSTR from ctypes.wintypes import BOOL, HWND, INT, LPARAM, LPCWSTR, LPWSTR
@ -236,7 +136,7 @@ if __name__ == '__main__': # noqa: C901
return None return None
@ctypes.WINFUNCTYPE(BOOL, HWND, LPARAM) @ctypes.WINFUNCTYPE(BOOL, HWND, LPARAM)
def enumwindowsproc(window_handle, l_param): def enumwindowsproc(window_handle, l_param): # noqa: CCR001
""" """
Determine if any window for the Application exists. Determine if any window for the Application exists.
@ -259,7 +159,6 @@ if __name__ == '__main__': # noqa: C901
if GetProcessHandleFromHwnd(window_handle): if GetProcessHandleFromHwnd(window_handle):
# If GetProcessHandleFromHwnd succeeds then the app is already running as this user # If GetProcessHandleFromHwnd succeeds then the app is already running as this user
if len(sys.argv) > 1 and sys.argv[1].startswith(protocolhandler_redirect): if len(sys.argv) > 1 and sys.argv[1].startswith(protocolhandler_redirect):
logger.debug('Invoked with edmc:// protocol handler arg')
CoInitializeEx(0, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE) CoInitializeEx(0, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE)
# Wait for it to be responsive to avoid ShellExecute recursing # Wait for it to be responsive to avoid ShellExecute recursing
ShowWindow(window_handle, SW_RESTORE) ShowWindow(window_handle, SW_RESTORE)
@ -281,7 +180,31 @@ if __name__ == '__main__': # noqa: C901
# Ref: <https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-enumwindows> # Ref: <https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-enumwindows>
EnumWindows(enumwindowsproc, 0) EnumWindows(enumwindowsproc, 0)
return 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!")
return True # Lie that no other instances are running.
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")
journal_dir_lockfile.flush()
logger.trace('Done')
return True
def already_running_popup(): def already_running_popup():
"""Create the "already running" popup.""" """Create the "already running" popup."""
@ -306,18 +229,26 @@ if __name__ == '__main__': # noqa: C901
root.mainloop() root.mainloop()
journal_lock = JournalLock() journal_dir: str = config.get_str('journaldir') or config.default_journal_dir
locked = journal_lock.obtain_lock() # 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')
handle_edmc_callback_or_foregrounding() # 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}")
if locked == JournalLockResult.ALREADY_LOCKED: else:
if not no_other_instance_running():
# There's a copy already running. # There's a copy already running.
logger.info("An EDMarketConnector.exe process was already running, exiting.") logger.info("An EDMarketConnector.exe process was already running, exiting.")
# To be sure the user knows, we need a popup # To be sure the user knows, we need a popup
if not args.edmc:
already_running_popup() already_running_popup()
# If the user closes the popup with the 'X', not the 'OK' button we'll # If the user closes the popup with the 'X', not the 'OK' button we'll
# reach here. # reach here.
@ -353,7 +284,7 @@ import tkinter as tk
import tkinter.filedialog import tkinter.filedialog
import tkinter.font import tkinter.font
import tkinter.messagebox import tkinter.messagebox
from tkinter import Toplevel, ttk from tkinter import ttk
import commodity import commodity
import companion import companion
@ -602,7 +533,7 @@ class AppWindow(object):
# update geometry # update geometry
if config.get_str('geometry'): if config.get_str('geometry'):
match = re.match(r'\+([\-\d]+)\+([\-\d]+)', config.get_str('geometry')) # noqa: W605 match = re.match(r'\+([\-\d]+)\+([\-\d]+)', config.get_str('geometry'))
if match: if match:
if platform == 'darwin': if platform == 'darwin':
# http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7 # http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7
@ -1140,7 +1071,11 @@ class AppWindow(object):
def shipyard_url(self, shipname: str) -> str: def shipyard_url(self, shipname: str) -> str:
"""Despatch a ship URL to the configured handler.""" """Despatch a ship URL to the configured handler."""
if not bool(config.get_int("use_alt_shipyard_open")): if not bool(config.get_int("use_alt_shipyard_open")):
return plug.invoke(config.get_str('shipyard_provider'), 'EDSY', 'shipyard_url', monitor.ship(), monitor.is_beta) return plug.invoke(config.get_str('shipyard_provider'),
'EDSY',
'shipyard_url',
monitor.ship(),
monitor.is_beta)
# Avoid file length limits if possible # Avoid file length limits if possible
provider = config.get_str('shipyard_provider', default='EDSY') provider = config.get_str('shipyard_provider', default='EDSY')
@ -1489,36 +1424,7 @@ def show_killswitch_poppup(root=None):
# Run the app # Run the app
if __name__ == "__main__": if __name__ == "__main__": # noqa: C901
# Command-line arguments
parser = argparse.ArgumentParser(
prog=appname,
description="Utilises Elite Dangerous Journal files and the Frontier "
"Companion API (CAPI) service to gather data about a "
"player's state and actions to upload to third-party sites "
"such as EDSM, Inara.cz and EDDB."
)
parser.add_argument(
'--trace',
help='Set the Debug logging loglevel to TRACE',
action='store_true',
)
parser.add_argument(
'--reset-ui',
help='reset UI theme and transparency to defaults',
action='store_true'
)
args = parser.parse_args()
if args.trace:
logger.setLevel(logging.TRACE)
edmclogger.set_channels_loglevel(logging.TRACE)
else:
edmclogger.set_channels_loglevel(logging.DEBUG)
logger.info(f'Startup v{appversion} : Running on Python v{sys.version}') logger.info(f'Startup v{appversion} : Running on Python v{sys.version}')
logger.debug(f'''Platform: {sys.platform} {sys.platform == "win32" and sys.getwindowsversion()} logger.debug(f'''Platform: {sys.platform} {sys.platform == "win32" and sys.getwindowsversion()}
argv[0]: {sys.argv[0]} argv[0]: {sys.argv[0]}
@ -1610,6 +1516,7 @@ sys.path: {sys.path}'''
@property @property
def test_prop(self): def test_prop(self):
"""Test property."""
logger.debug("test log from property") logger.debug("test log from property")
return "Test property is testy" return "Test property is testy"