#!/usr/bin/env python3 # -*- coding: utf-8 -*- import argparse import html import json import locale import re 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 from typing import TYPE_CHECKING 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, 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('--suppress-dupe-process-popup', help='Suppress the popup from when the application detects another instance already running', 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' ) args = parser.parse_args() if args.trace: logger.setLevel(logging.TRACE) edmclogger.set_channels_loglevel(logging.TRACE) else: edmclogger.set_channels_loglevel(logging.DEBUG) if args.force_localserver_for_auth: config.set_auth_force_localserver() def no_other_instance_running() -> bool: # noqa: CCR001 """ Ensure only one copy of the app is running for the configured journal directory. :returns: True if we are the single instance, else False. """ logger.trace('Begin...') if platform == 'win32': logger.trace('win32, using msvcrt') # win32 doesn't have fcntl, so we have to use msvcrt import msvcrt logger.trace(f'journal_dir_lockfile = {journal_dir_lockfile!r}') 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 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): """ 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!") return True # Lie about there being no other 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") journal_dir_lockfile.flush() logger.trace('Done') return True def already_running_popup(): """Create the "already running" popup.""" import tkinter as tk from tkinter import ttk # Check for CL arg that suppresses this popup. if args.suppress_dupe_process_popup: sys.exit(0) 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() 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') # 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}") else: if not no_other_instance_running(): # There's a copy already running. 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) if getattr(sys, 'frozen', False): # Now that we're sure we're the only instance running we can truncate the logfile logger.trace('Truncating plain logfile') sys.stdout.seek(0) sys.stdout.truncate() # See EDMCLogging.py docs. # isort: off 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 if platform == 'win32': chdir(dirname(sys.path[0])) # Allow executable to be invoked from any cwd environ['TCL_LIBRARY'] = join(dirname(sys.path[0]), 'lib', 'tcl') environ['TK_LIBRARY'] = join(dirname(sys.path[0]), 'lib', 'tk') import tkinter as tk import tkinter.filedialog import tkinter.font import tkinter.messagebox from tkinter import ttk import commodity import companion import plug import prefs import stats import td from commodity import COMMODITY_CSV from dashboard import dashboard from hotkey import hotkeymgr 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] SHIPYARD_HTML_TEMPLATE = """ Redirecting you to your {ship_name} at {provider_name}... You should be redirected to your {ship_name} at {provider_name} shortly... """ class AppWindow(object): # Tkinter Event types EVENT_KEYPRESS = 2 EVENT_BUTTON = 4 EVENT_VIRTUAL = 35 def __init__(self, master): # noqa: C901 self.holdofftime = config.getint('querytime') + companion.holdoff self.w = master self.w.title(applongname) self.w.rowconfigure(0, weight=1) self.w.columnconfigure(0, weight=1) self.prefsdialog = None plug.load_plugins(master) if platform != 'darwin': if platform == 'win32': self.w.wm_iconbitmap(default='EDMarketConnector.ico') else: self.w.tk.call('wm', 'iconphoto', self.w, '-default', tk.PhotoImage(file=join(config.respath, 'EDMarketConnector.png'))) # noqa: E501 self.theme_icon = tk.PhotoImage( data='R0lGODlhFAAQAMZQAAoKCQoKCgsKCQwKCQsLCgwLCg4LCQ4LCg0MCg8MCRAMCRANChINCREOChIOChQPChgQChgRCxwTCyYVCSoXCS0YCTkdCTseCT0fCTsjDU0jB0EnDU8lB1ElB1MnCFIoCFMoCEkrDlkqCFwrCGEuCWIuCGQvCFs0D1w1D2wyCG0yCF82D182EHE0CHM0CHQ1CGQ5EHU2CHc3CHs4CH45CIA6CIE7CJdECIdLEolMEohQE5BQE41SFJBTE5lUE5pVE5RXFKNaFKVbFLVjFbZkFrxnFr9oFsNqFsVrF8RsFshtF89xF9NzGNh1GNl2GP+KG////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////yH5BAEKAH8ALAAAAAAUABAAAAeegAGCgiGDhoeIRDiIjIZGKzmNiAQBQxkRTU6am0tPCJSGShuSAUcLoIIbRYMFra4FAUgQAQCGJz6CDQ67vAFJJBi0hjBBD0w9PMnJOkAiJhaIKEI7HRoc19ceNAolwbWDLD8uAQnl5ga1I9CHEjEBAvDxAoMtFIYCBy+kFDKHAgM3ZtgYSLAGgwkp3pEyBOJCC2ELB31QATGioAoVAwEAOw==') # noqa: E501 self.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( 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()) frame.grid(sticky=tk.NSEW) frame.columnconfigure(1, weight=1) self.cmdr_label = tk.Label(frame) self.ship_label = tk.Label(frame) self.system_label = tk.Label(frame) self.station_label = tk.Label(frame) self.cmdr_label.grid(row=1, column=0, sticky=tk.W) self.ship_label.grid(row=2, column=0, sticky=tk.W) self.system_label.grid(row=3, column=0, sticky=tk.W) self.station_label.grid(row=4, column=0, sticky=tk.W) self.cmdr = tk.Label(frame, compound=tk.RIGHT, anchor=tk.W, name='cmdr') self.ship = HyperlinkLabel(frame, compound=tk.RIGHT, url=self.shipyard_url, name='ship') self.system = HyperlinkLabel(frame, compound=tk.RIGHT, url=self.system_url, popup_copy=True, name='system') self.station = HyperlinkLabel(frame, compound=tk.RIGHT, url=self.station_url, name='station') self.cmdr.grid(row=1, column=1, sticky=tk.EW) self.ship.grid(row=2, column=1, sticky=tk.EW) self.system.grid(row=3, column=1, sticky=tk.EW) self.station.grid(row=4, column=1, sticky=tk.EW) for plugin in plug.PLUGINS: appitem = plugin.get_app(frame) if appitem: tk.Frame(frame, highlightthickness=1).grid(columnspan=2, sticky=tk.EW) # separator if isinstance(appitem, tuple) and len(appitem) == 2: row = frame.grid_size()[1] appitem[0].grid(row=row, column=0, sticky=tk.W) appitem[1].grid(row=row, column=1, sticky=tk.EW) else: appitem.grid(columnspan=2, sticky=tk.EW) # Update button in main window self.button = ttk.Button(frame, text=_('Update'), width=28, default=tk.ACTIVE, state=tk.DISABLED) self.theme_button = tk.Label(frame, width=32 if platform == 'darwin' else 28, state=tk.DISABLED) self.status = tk.Label(frame, name='status', anchor=tk.W) row = frame.grid_size()[1] self.button.grid(row=row, columnspan=2, sticky=tk.NSEW) self.theme_button.grid(row=row, columnspan=2, sticky=tk.NSEW) theme.register_alternate((self.button, self.theme_button, self.theme_button), {'row': row, 'columnspan': 2, 'sticky': tk.NSEW}) # noqa: E501 self.status.grid(columnspan=2, sticky=tk.EW) self.button.bind('', self.getandsend) theme.button_bind(self.theme_button, self.getandsend) for child in frame.winfo_children(): child.grid_configure(padx=5, pady=(platform != 'win32' or isinstance(child, tk.Frame)) and 2 or 0) self.menubar = tk.Menu() if platform == 'darwin': # Can't handle (de)iconify if topmost is set, so suppress iconify button # http://wiki.tcl.tk/13428 and p15 of # https://developer.apple.com/legacy/library/documentation/Carbon/Conceptual/HandlingWindowsControls/windowscontrols.pdf root.call('tk::unsupported::MacWindowStyle', 'style', root, 'document', 'closeBox resizable') # https://www.tcl.tk/man/tcl/TkCmd/menu.htm self.system_menu = tk.Menu(self.menubar, name='apple') self.system_menu.add_command(command=lambda: self.w.call('tk::mac::standardAboutPanel')) self.system_menu.add_command(command=lambda: self.updater.checkForUpdates()) self.menubar.add_cascade(menu=self.system_menu) self.file_menu = tk.Menu(self.menubar, name='file') self.file_menu.add_command(command=self.save_raw) self.menubar.add_cascade(menu=self.file_menu) self.edit_menu = tk.Menu(self.menubar, name='edit') self.edit_menu.add_command(accelerator='Command-c', state=tk.DISABLED, command=self.copy) self.menubar.add_cascade(menu=self.edit_menu) self.w.bind('', self.copy) self.view_menu = tk.Menu(self.menubar, name='view') self.view_menu.add_command(command=lambda: stats.StatsDialog(self)) self.menubar.add_cascade(menu=self.view_menu) window_menu = tk.Menu(self.menubar, name='window') self.menubar.add_cascade(menu=window_menu) self.help_menu = tk.Menu(self.menubar, name='help') self.w.createcommand("::tk::mac::ShowHelp", self.help_general) self.help_menu.add_command(command=self.help_privacy) self.help_menu.add_command(command=self.help_releases) self.menubar.add_cascade(menu=self.help_menu) self.w['menu'] = self.menubar # https://www.tcl.tk/man/tcl/TkCmd/tk_mac.htm self.w.call('set', 'tk::mac::useCompatibilityMetrics', '0') self.w.createcommand('tkAboutDialog', lambda: self.w.call('tk::mac::standardAboutPanel')) self.w.createcommand("::tk::mac::Quit", self.onexit) self.w.createcommand("::tk::mac::ShowPreferences", lambda: prefs.PreferencesDialog(self.w, self.postprefs)) self.w.createcommand("::tk::mac::ReopenApplication", self.w.deiconify) # click on app in dock = restore self.w.protocol("WM_DELETE_WINDOW", self.w.withdraw) # close button shouldn't quit app self.w.resizable(tk.FALSE, tk.FALSE) # Can't be only resizable on one axis else: self.file_menu = self.view_menu = tk.Menu(self.menubar, tearoff=tk.FALSE) self.file_menu.add_command(command=lambda: stats.StatsDialog(self)) self.file_menu.add_command(command=self.save_raw) self.file_menu.add_command(command=lambda: prefs.PreferencesDialog(self.w, self.postprefs)) self.file_menu.add_separator() self.file_menu.add_command(command=self.onexit) self.menubar.add_cascade(menu=self.file_menu) self.edit_menu = tk.Menu(self.menubar, tearoff=tk.FALSE) self.edit_menu.add_command(accelerator='Ctrl+C', state=tk.DISABLED, command=self.copy) self.menubar.add_cascade(menu=self.edit_menu) self.help_menu = tk.Menu(self.menubar, tearoff=tk.FALSE) self.help_menu.add_command(command=self.help_general) self.help_menu.add_command(command=self.help_privacy) self.help_menu.add_command(command=self.help_releases) self.help_menu.add_command(command=lambda: self.updater.checkForUpdates()) self.help_menu.add_command(command=lambda: not self.HelpAbout.showing and self.HelpAbout(self.w)) self.menubar.add_cascade(menu=self.help_menu) if platform == 'win32': # Must be added after at least one "real" menu entry self.always_ontop = tk.BooleanVar(value=config.getint('always_ontop')) self.system_menu = tk.Menu(self.menubar, name='system', tearoff=tk.FALSE) self.system_menu.add_separator() self.system_menu.add_checkbutton(label=_('Always on top'), variable=self.always_ontop, command=self.ontop_changed) # Appearance setting self.menubar.add_cascade(menu=self.system_menu) self.w.bind('', self.copy) self.w.protocol("WM_DELETE_WINDOW", self.onexit) theme.register(self.menubar) # menus and children aren't automatically registered theme.register(self.file_menu) theme.register(self.edit_menu) theme.register(self.help_menu) # Alternate title bar and menu for dark theme self.theme_menubar = tk.Frame(frame) self.theme_menubar.columnconfigure(2, weight=1) theme_titlebar = tk.Label(self.theme_menubar, 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.drag_offset = 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_file_menu = tk.Label(self.theme_menubar, anchor=tk.W) self.theme_file_menu.grid(row=1, column=0, padx=5, 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=5, sticky=tk.EW) theme.register(self.theme_minimize) # images aren't automatically registered theme.register(self.theme_close) self.blank_menubar = tk.Frame(frame) tk.Label(self.blank_menubar).grid() tk.Label(self.blank_menubar).grid() 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) # update geometry if config.get('geometry'): match = re.match('\+([\-\d]+)\+([\-\d]+)', config.get('geometry')) # noqa: W605 if match: if platform == 'darwin': # http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7 if int(match.group(2)) >= 0: self.w.geometry(config.get('geometry')) elif platform == 'win32': # Check that the titlebar will be at least partly on screen import ctypes from ctypes.wintypes import POINT # https://msdn.microsoft.com/en-us/library/dd145064 MONITOR_DEFAULTTONULL = 0 # noqa: N806 if ctypes.windll.user32.MonitorFromPoint(POINT(int(match.group(1)) + 16, int(match.group(2)) + 16), MONITOR_DEFAULTTONULL): self.w.geometry(config.get('geometry')) else: self.w.geometry(config.get('geometry')) self.w.attributes('-topmost', config.getint('always_ontop') and 1 or 0) theme.register(frame) theme.apply(self.w) self.w.bind('', self.onmap) # Special handling for overrideredict self.w.bind('', self.onenter) # Special handling for transparency self.w.bind('', self.onenter) # Special handling for transparency self.w.bind('', self.onleave) # Special handling for transparency self.w.bind('', self.onleave) # Special handling for transparency self.w.bind('', self.getandsend) self.w.bind('', self.getandsend) self.w.bind_all('<>', self.getandsend) # Hotkey monitoring self.w.bind_all('<>', self.journal_event) # Journal monitoring self.w.bind_all('<>', self.dashboard_event) # Dashboard monitoring self.w.bind_all('<>', self.plugin_error) # Statusbar self.w.bind_all('<>', self.auth) # cAPI auth self.w.bind_all('<>', self.onexit) # Updater # Start a protocol handler to handle cAPI registration. Requires main loop to be running. self.w.after_idle(lambda: protocolhandler.start(self.w)) # Load updater after UI creation (for WinSparkle) import update if getattr(sys, 'frozen', False): # Running in frozen .exe, so use (Win)Sparkle self.updater = update.Updater(tkroot=self.w, provider='external') else: self.updater = update.Updater(tkroot=self.w, provider='internal') self.updater.checkForUpdates() # Sparkle / WinSparkle does this automatically for packaged apps try: config.get_password('') # Prod SecureStorage on Linux to initialise except RuntimeError: pass # Migration from <= 3.30 for username in config.get('fdev_usernames') or []: config.delete_password(username) config.delete('fdev_usernames') config.delete('username') config.delete('password') config.delete('logdir') self.postprefs(False) # Companion login happens in callback from monitor # callback after the Preferences dialog is applied def postprefs(self, dologin=True): self.prefsdialog = None self.set_labels() # in case language has changed # Reset links in case plugins changed them self.ship.configure(url=self.shipyard_url) self.system.configure(url=self.system_url) self.station.configure(url=self.station_url) # (Re-)install hotkey monitoring hotkeymgr.register(self.w, config.getint('hotkey_code'), config.getint('hotkey_mods')) # (Re-)install log monitoring if not monitor.start(self.w): self.status['text'] = f'Error: Check {_("E:D journal file location")}' if dologin and monitor.cmdr: self.login() # Login if not already logged in with this Cmdr # set main window labels, e.g. after language change def set_labels(self): self.cmdr_label['text'] = _('Cmdr') + ':' # Main window # Multicrew role label in main window self.ship_label['text'] = (monitor.state['Captain'] and _('Role') or _('Ship')) + ':' # Main window self.system_label['text'] = _('System') + ':' # Main window self.station_label['text'] = _('Station') + ':' # Main window self.button['text'] = self.theme_button['text'] = _('Update') # Update button in main window if platform == 'darwin': self.menubar.entryconfigure(1, label=_('File')) # Menu title self.menubar.entryconfigure(2, label=_('Edit')) # Menu title self.menubar.entryconfigure(3, label=_('View')) # Menu title on OSX self.menubar.entryconfigure(4, label=_('Window')) # Menu title on OSX self.menubar.entryconfigure(5, label=_('Help')) # Menu title self.system_menu.entryconfigure(0, label=_("About {APP}").format(APP=applongname)) # App menu entry on OSX self.system_menu.entryconfigure(1, label=_("Check for Updates...")) # Menu item self.file_menu.entryconfigure(0, label=_('Save Raw Data...')) # Menu item self.view_menu.entryconfigure(0, label=_('Status')) # Menu item self.help_menu.entryconfigure(1, label=_('Privacy Policy')) # Help menu item self.help_menu.entryconfigure(2, label=_('Release Notes')) # Help menu item else: self.menubar.entryconfigure(1, label=_('File')) # Menu title self.menubar.entryconfigure(2, label=_('Edit')) # Menu title self.menubar.entryconfigure(3, label=_('Help')) # Menu title self.theme_file_menu['text'] = _('File') # Menu title self.theme_edit_menu['text'] = _('Edit') # Menu title self.theme_help_menu['text'] = _('Help') # Menu title # File menu self.file_menu.entryconfigure(0, label=_('Status')) # Menu item self.file_menu.entryconfigure(1, label=_('Save Raw Data...')) # Menu item self.file_menu.entryconfigure(2, label=_('Settings')) # Item in the File menu on Windows self.file_menu.entryconfigure(4, label=_('Exit')) # Item in the File menu on Windows # Help menu self.help_menu.entryconfigure(0, label=_('Documentation')) # Help menu item self.help_menu.entryconfigure(1, label=_('Privacy Policy')) # Help menu item self.help_menu.entryconfigure(2, label=_('Release Notes')) # Help menu item self.help_menu.entryconfigure(3, label=_('Check for Updates...')) # Menu item self.help_menu.entryconfigure(4, label=_("About {APP}").format(APP=applongname)) # App menu entry # Edit menu self.edit_menu.entryconfigure(0, label=_('Copy')) # As in Copy and Paste def login(self): if not self.status['text']: self.status['text'] = _('Logging in...') self.button['state'] = self.theme_button['state'] = tk.DISABLED if platform == 'darwin': self.view_menu.entryconfigure(0, state=tk.DISABLED) # Status self.file_menu.entryconfigure(0, state=tk.DISABLED) # Save Raw Data else: self.file_menu.entryconfigure(0, state=tk.DISABLED) # Status self.file_menu.entryconfigure(1, state=tk.DISABLED) # Save Raw Data self.w.update_idletasks() try: if companion.session.login(monitor.cmdr, monitor.is_beta): # Successfully authenticated with the Frontier website self.status['text'] = _('Authentication successful') if platform == 'darwin': self.view_menu.entryconfigure(0, state=tk.NORMAL) # Status self.file_menu.entryconfigure(0, state=tk.NORMAL) # Save Raw Data else: self.file_menu.entryconfigure(0, state=tk.NORMAL) # Status self.file_menu.entryconfigure(1, state=tk.NORMAL) # Save Raw Data except (companion.CredentialsError, companion.ServerError, companion.ServerLagging) as e: self.status['text'] = str(e) except Exception as e: logger.debug('Frontier CAPI Auth', exc_info=e) self.status['text'] = str(e) self.cooldown() 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') play_bad = False if not monitor.cmdr or not monitor.mode or monitor.state['Captain'] or not monitor.system: return # In CQC or on crew - do nothing if companion.session.state == companion.Session.STATE_AUTH: # Attempt another Auth self.login() return if not retrying: if time() < self.holdofftime: # Was invoked by key while in cooldown self.status['text'] = '' if play_sound and (self.holdofftime - time()) < companion.holdoff * 0.75: hotkeymgr.play_bad() # Don't play sound in first few seconds to prevent repeats return elif play_sound: hotkeymgr.play_good() self.status['text'] = _('Fetching data...') self.button['state'] = self.theme_button['state'] = tk.DISABLED self.w.update_idletasks() try: querytime = int(time()) data = companion.session.station() config.set('querytime', querytime) # Validation if not data.get('commander', {}).get('name'): self.status['text'] = _("Who are you?!") # Shouldn't happen elif (not data.get('lastSystem', {}).get('name') or (data['commander'].get('docked') and not data.get('lastStarport', {}).get('name'))): # Only care if docked self.status['text'] = _("Where are you?!") # Shouldn't happen elif not data.get('ship', {}).get('name') or not data.get('ship', {}).get('modules'): self.status['text'] = _("What are you flying?!") # Shouldn't happen elif monitor.cmdr and data['commander']['name'] != monitor.cmdr: # Companion API return doesn't match Journal raise companion.CmdrError() elif ((auto_update and not data['commander'].get('docked')) or (data['lastSystem']['name'] != monitor.system) or ((data['commander']['docked'] and data['lastStarport']['name'] or None) != monitor.station) or (data['ship']['id'] != monitor.state['ShipID']) or (data['ship']['name'].lower() != monitor.state['ShipType'])): raise companion.ServerLagging() else: if __debug__: # Recording if isdir('dump'): with open('dump/{system}{station}.{timestamp}.json'.format( system=data['lastSystem']['name'], station=data['commander'].get('docked') and '.' + data['lastStarport']['name'] or '', timestamp=strftime('%Y-%m-%dT%H.%M.%S', localtime())), 'wb') as h: h.write(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True, separators=(',', ': ')).encode('utf-8')) if not monitor.state['ShipType']: # Started game in SRV or fighter self.ship['text'] = companion.ship_map.get(data['ship']['name'].lower(), data['ship']['name']) monitor.state['ShipID'] = data['ship']['id'] monitor.state['ShipType'] = data['ship']['name'].lower() if data['commander'].get('credits') is not None: monitor.state['Credits'] = data['commander']['credits'] monitor.state['Loan'] = data['commander'].get('debt', 0) # stuff we can do when not docked err = plug.notify_newdata(data, monitor.is_beta) self.status['text'] = err and err or '' if err: play_bad = True # Export market data if config.getint('output') & (config.OUT_STATION_ANY): if not data['commander'].get('docked'): if not self.status['text']: # Signal as error because the user might actually be docked # but the server hosting the Companion API hasn't caught up self.status['text'] = _("You're not docked at a station!") play_bad = True # Ignore possibly missing shipyard info elif (config.getint('output') & config.OUT_MKT_EDDN) \ and not (data['lastStarport'].get('commodities') or data['lastStarport'].get('modules')): if not self.status['text']: self.status['text'] = _("Station doesn't have anything!") elif not data['lastStarport'].get('commodities'): if not self.status['text']: self.status['text'] = _("Station doesn't have a market!") elif config.getint('output') & (config.OUT_MKT_CSV | config.OUT_MKT_TD): # Fixup anomalies in the commodity data fixed = companion.fixup(data) if config.getint('output') & config.OUT_MKT_CSV: commodity.export(fixed, COMMODITY_CSV) if config.getint('output') & config.OUT_MKT_TD: td.export(fixed) self.holdofftime = querytime + companion.holdoff # Companion API problem except companion.ServerLagging as e: if retrying: self.status['text'] = str(e) play_bad = True else: # Retry once if Companion server is unresponsive self.w.after(int(SERVER_RETRY * 1000), lambda: self.getandsend(event, True)) return # early exit to avoid starting cooldown count except companion.CmdrError as e: # Companion API return doesn't match Journal self.status['text'] = str(e) play_bad = True companion.session.invalidate() self.login() except Exception as e: # Including CredentialsError, ServerError logger.debug('"other" exception', exc_info=e) self.status['text'] = str(e) play_bad = True if not self.status['text']: # no errors self.status['text'] = strftime(_('Last updated at %H:%M:%S'), localtime(querytime)) if play_sound and play_bad: hotkeymgr.play_bad() self.cooldown() def retry_for_shipyard(self, tries): # Try again to get shipyard data and send to EDDN. Don't report errors if can't get or send the data. try: data = companion.session.station() if data['commander'].get('docked'): if data.get('lastStarport', {}).get('ships'): report = 'Success' else: report = 'Failure' else: report = 'Undocked!' logger.debug(f'Retry for shipyard - {report}') if not data['commander'].get('docked'): # might have un-docked while we were waiting for retry in which case station data is unreliable pass elif (data.get('lastSystem', {}).get('name') == monitor.system and data.get('lastStarport', {}).get('name') == monitor.station and data.get('lastStarport', {}).get('ships', {}).get('shipyard_list')): self.eddn.export_shipyard(data, monitor.is_beta) elif tries > 1: # bogus data - retry self.w.after(int(SERVER_RETRY * 1000), lambda: self.retry_for_shipyard(tries - 1)) except Exception: pass # Handle event(s) from the journal def journal_event(self, event: str): # noqa: C901 """ Handle a Journal event passed through event queue from monitor.py. :param event: string JSON data of the event :return: """ def crewroletext(role): # Return translated crew role. Needs to be dynamic to allow for changing language. return { None: '', 'Idle': '', 'FighterCon': _('Fighter'), # Multicrew role 'FireCon': _('Gunner'), # Multicrew role 'FlightCon': _('Helm'), # Multicrew role }.get(role, role) if monitor.thread is None: logger.debug('monitor.thread is None, assuming shutdown and returning') return while True: entry = monitor.get_entry() if not entry: logger.trace('No entry from monitor.get_entry()') return # Update main window self.cooldown() if monitor.cmdr and monitor.state['Captain']: self.cmdr['text'] = f'{monitor.cmdr} / {monitor.state["Captain"]}' self.ship_label['text'] = _('Role') + ':' # Multicrew role label in main window self.ship.configure(state=tk.NORMAL, text=crewroletext(monitor.state['Role']), url=None) elif monitor.cmdr: if monitor.group: self.cmdr['text'] = f'{monitor.cmdr} / {monitor.group}' else: self.cmdr['text'] = monitor.cmdr self.ship_label['text'] = _('Ship') + ':' # Main window self.ship.configure( text=monitor.state['ShipName'] or companion.ship_map.get(monitor.state['ShipType'], monitor.state['ShipType']) or '', url=self.shipyard_url) else: self.cmdr['text'] = '' self.ship_label['text'] = _('Ship') + ':' # Main window self.ship['text'] = '' self.edit_menu.entryconfigure(0, state=monitor.system and tk.NORMAL or tk.DISABLED) # Copy if entry['event'] in ( 'Undocked', 'StartJump', 'SetUserShipName', 'ShipyardBuy', 'ShipyardSell', 'ShipyardSwap', 'ModuleBuy', 'ModuleSell', 'MaterialCollected', 'MaterialDiscarded', 'ScientificResearch', 'EngineerCraft', 'Synthesis', 'JoinACrew'): self.status['text'] = '' # Periodically clear any old error self.w.update_idletasks() # Companion login if entry['event'] in [None, 'StartUp', 'NewCommander', 'LoadGame'] and monitor.cmdr: if not config.get('cmdrs') or monitor.cmdr not in config.get('cmdrs'): config.set('cmdrs', (config.get('cmdrs') or []) + [monitor.cmdr]) self.login() if not entry['event'] or not monitor.mode: logger.trace('Startup or in CQC, returning') return # Startup or in CQC if entry['event'] in ['StartUp', 'LoadGame'] and monitor.started: logger.info('Startup or LoadGame event') # Disable WinSparkle automatic update checks, IFF configured to do so when in-game if config.getint('disable_autoappupdatecheckingame') and 1: self.updater.setAutomaticUpdatesCheck(False) logger.info('Monitor: Disable WinSparkle automatic update checks') # Can start dashboard monitoring if not dashboard.start(self.w, monitor.started): logger.info("Can't start Status monitoring") # Export loadout if entry['event'] == 'Loadout' and not monitor.state['Captain'] \ and config.getint('output') & config.OUT_SHIP: monitor.export_ship() # Plugins err = plug.notify_journal_entry(monitor.cmdr, monitor.is_beta, monitor.system, monitor.station, entry, monitor.state) if err: self.status['text'] = err if not config.getint('hotkey_mute'): hotkeymgr.play_bad() # Auto-Update after docking, but not if auth callback is pending if entry['event'] in ('StartUp', 'Location', 'Docked') \ and monitor.station \ and not config.getint('output') & config.OUT_MKT_MANUAL \ and config.getint('output') & config.OUT_STATION_ANY \ and companion.session.state != companion.Session.STATE_AUTH: self.w.after(int(SERVER_RETRY * 1000), self.getandsend) if entry['event'] == 'ShutDown': # Enable WinSparkle automatic update checks # NB: Do this blindly, in case option got changed whilst in-game self.updater.setAutomaticUpdatesCheck(True) logger.info('Monitor: Enable WinSparkle automatic update checks') # cAPI auth def auth(self, event=None): try: companion.session.auth_callback() # Successfully authenticated with the Frontier website self.status['text'] = _('Authentication successful') if platform == 'darwin': self.view_menu.entryconfigure(0, state=tk.NORMAL) # Status self.file_menu.entryconfigure(0, state=tk.NORMAL) # Save Raw Data else: self.file_menu.entryconfigure(0, state=tk.NORMAL) # Status self.file_menu.entryconfigure(1, state=tk.NORMAL) # Save Raw Data except companion.ServerError as e: self.status['text'] = str(e) except Exception as e: logger.debug('Frontier CAPI Auth:', exc_info=e) self.status['text'] = str(e) self.cooldown() # Handle Status event def dashboard_event(self, event): entry = dashboard.status if entry: # Currently we don't do anything with these events err = plug.notify_dashboard_entry(monitor.cmdr, monitor.is_beta, entry) if err: self.status['text'] = err if not config.getint('hotkey_mute'): hotkeymgr.play_bad() # Display asynchronous error from plugin def plugin_error(self, event=None): if plug.last_error.get('msg'): self.status['text'] = plug.last_error['msg'] self.w.update_idletasks() if not config.getint('hotkey_mute'): hotkeymgr.play_bad() def shipyard_url(self, shipname): if not bool(config.getint("use_alt_shipyard_open")): return plug.invoke(config.get('shipyard_provider'), 'EDSY', 'shipyard_url', monitor.ship(), monitor.is_beta) # Avoid file length limits if possible provider = config.get('shipyard_provider') or 'EDSY' target = plug.invoke(config.get('shipyard_provider'), 'EDSY', 'shipyard_url', monitor.ship(), monitor.is_beta) file_name = join(config.app_dir, "last_shipyard.html") with open(file_name, 'w') as f: print(SHIPYARD_HTML_TEMPLATE.format( link=html.escape(str(target)), provider_name=html.escape(str(provider)), ship_name=html.escape(str(shipname)) ), file=f) return f'file://localhost/{file_name}' def system_url(self, system): return plug.invoke(config.get('system_provider'), 'EDSM', 'system_url', monitor.system) def station_url(self, station): return plug.invoke(config.get('station_provider'), 'eddb', 'station_url', monitor.system, monitor.station) def cooldown(self): if time() < self.holdofftime: # Update button in main window self.button['text'] = self.theme_button['text'] \ = _('cooldown {SS}s').format(SS=int(self.holdofftime - time())) self.w.after(1000, self.cooldown) else: self.button['text'] = self.theme_button['text'] = _('Update') # Update button in main window self.button['state'] = self.theme_button['state'] = (monitor.cmdr and monitor.mode and not monitor.state['Captain'] and monitor.system and tk.NORMAL or tk.DISABLED) def ontop_changed(self, event=None): config.set('always_ontop', self.always_ontop.get()) self.w.wm_attributes('-topmost', self.always_ontop.get()) def copy(self, event=None): if monitor.system: self.w.clipboard_clear() self.w.clipboard_append(monitor.station and f'{monitor.system},{monitor.station}' or monitor.system) def help_general(self, event=None): webbrowser.open('https://github.com/EDCD/EDMarketConnector/wiki') def help_privacy(self, event=None): webbrowser.open('https://github.com/EDCD/EDMarketConnector/wiki/Privacy-Policy') def help_releases(self, event=None): webbrowser.open('https://github.com/EDCD/EDMarketConnector/releases') class HelpAbout(tk.Toplevel): showing = False def __init__(self, parent): if self.__class__.showing: return self.__class__.showing = True tk.Toplevel.__init__(self, parent) self.parent = parent self.title(_('About {APP}').format(APP=applongname)) if parent.winfo_viewable(): self.transient(parent) # position over parent # http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7 if platform != 'darwin' or parent.winfo_rooty() > 0: self.geometry(f'+{parent.winfo_rootx():d}+{parent.winfo_rooty():d}') # remove decoration if platform == 'win32': self.attributes('-toolwindow', tk.TRUE) self.resizable(tk.FALSE, tk.FALSE) frame = ttk.Frame(self) frame.grid(sticky=tk.NSEW) row = 1 ############################################################ # applongname self.appname_label = tk.Label(frame, text=applongname) self.appname_label.grid(row=row, columnspan=3, sticky=tk.EW) row += 1 ############################################################ ############################################################ # version ttk.Label(frame).grid(row=row, column=0) # spacer row += 1 self.appversion_label = tk.Label(frame, text=appversion) self.appversion_label.grid(row=row, column=0, sticky=tk.E) self.appversion = HyperlinkLabel(frame, compound=tk.RIGHT, text=_('Release Notes'), url='https://github.com/EDCD/EDMarketConnector/releases/tag/Release/' f'{appversion_nobuild}', underline=True) self.appversion.grid(row=row, column=2, sticky=tk.W) row += 1 ############################################################ ############################################################ # ############################################################ ############################################################ # ttk.Label(frame).grid(row=row, column=0) # spacer row += 1 self.copyright = tk.Label(frame, text=copyright) self.copyright.grid(row=row, columnspan=3, sticky=tk.EW) row += 1 ############################################################ ############################################################ # OK button to close the window ttk.Label(frame).grid(row=row, column=0) # spacer row += 1 button = ttk.Button(frame, text=_('OK'), command=self.apply) button.grid(row=row, column=2, sticky=tk.E) button.bind("", lambda event: self.apply()) self.protocol("WM_DELETE_WINDOW", self._destroy) ############################################################ logger.info(f'Current version is {appversion}') def apply(self): self._destroy() def _destroy(self): self.parent.wm_attributes('-topmost', config.getint('always_ontop') and 1 or 0) self.destroy() self.__class__.showing = False def save_raw(self): self.status['text'] = _('Fetching data...') self.w.update_idletasks() try: data = companion.session.station() self.status['text'] = '' default_extension: str = '' if platform == 'darwin': default_extension = '.json' last_system: str = data.get("lastSystem", {}).get("name", "Unknown") last_starport: str = '' if data['commander'].get('docked'): last_starport = '.' + data.get('lastStarport', {}).get('name', 'Unknown') timestamp: str = strftime('%Y-%m-%dT%H.%M.%S', localtime()) f = tkinter.filedialog.asksaveasfilename(parent=self.w, defaultextension=default_extension, filetypes=[('JSON', '.json'), ('All Files', '*')], initialdir=config.get('outdir'), initialfile=f'{last_system}{last_starport}.{timestamp}') if f: with open(f, 'wb') as h: h.write(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True, separators=(',', ': ')).encode('utf-8')) except companion.ServerError as e: self.status['text'] = str(e) except Exception as e: logger.debug('"other" exception', exc_info=e) self.status['text'] = str(e) def onexit(self, event=None): config.set_shutdown() # Signal we're in shutdown now. # http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7 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}') # Let the user know we're shutting down. self.status['text'] = _('Shutting down...') self.w.update_idletasks() logger.info('Starting shutdown procedures...') # First so it doesn't interrupt us logger.info('Closing update checker...') self.updater.close() # Earlier than anything else so plugin code can't interfere *and* it # won't still be running in a manner that might rely on something # we'd otherwise have already stopped. logger.info('Notifying plugins to stop...') plug.notify_stop() # Handling of application hotkeys now so the user can't possible cause # an issue via triggering one. logger.info('Unregistering hotkey manager...') hotkeymgr.unregister() # Now the main programmatic input methods logger.info('Closing dashboard...') dashboard.close() logger.info('Closing journal monitor...') monitor.close() # Frontier auth/CAPI handling logger.info('Closing protocol handler...') protocolhandler.close() logger.info('Closing Frontier CAPI sessions...') companion.session.close() # Now anything else. logger.info('Closing config...') config.close() logger.info('Destroying app window...') self.w.destroy() 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): if self.drag_offset: offset_x = event.x_root - self.drag_offset[0] offset_y = event.y_root - self.drag_offset[1] self.w.geometry(f'+{offset_x:d}+{offset_y:d}') def drag_end(self, event): self.drag_offset = None def oniconify(self, event=None): self.w.overrideredirect(0) # Can't iconize while overrideredirect self.w.iconify() self.w.update_idletasks() # Size and windows styles get recalculated here self.w.wait_visibility() # Need main window to be re-created before returning theme.active = None # So theme will be re-applied on map def onmap(self, event=None): if event.widget == self.w: theme.apply(self.w) def onenter(self, event=None): if config.getint('theme') > 1: self.w.attributes("-transparentcolor", '') self.blank_menubar.grid_remove() self.theme_menubar.grid(row=0, columnspan=2, sticky=tk.NSEW) def onleave(self, event=None): if config.getint('theme') > 1 and event.widget == self.w: self.w.attributes("-transparentcolor", 'grey4') self.theme_menubar.grid_remove() self.blank_menubar.grid(row=0, columnspan=2, sticky=tk.NSEW) def test_logging(): logger.debug('Test from EDMarketConnector.py top-level test_logging()') def log_locale(prefix: str) -> None: logger.debug(f'''Locale: {prefix} Locale LC_COLLATE: {locale.getlocale(locale.LC_COLLATE)} Locale LC_CTYPE: {locale.getlocale(locale.LC_CTYPE)} Locale LC_MONETARY: {locale.getlocale(locale.LC_MONETARY)} Locale LC_NUMERIC: {locale.getlocale(locale.LC_NUMERIC)} Locale LC_TIME: {locale.getlocale(locale.LC_TIME)}''' ) # Run the app 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]} exec_prefix: {sys.exec_prefix} executable: {sys.executable} sys.path: {sys.path}''' ) # We prefer a UTF-8 encoding gets set, but older Windows versions have # issues with this. From Windows 10 1903 onwards we can rely on the # manifest ActiveCodePage to set this, but that is silently ignored on # all previous Windows versions. # Trying to set a UTF-8 encoding on those older versions will fail with # locale.Error: unsupported locale setting # but we do need to make the attempt for when we're running from source. log_locale('Initial Locale') try: locale.setlocale(locale.LC_ALL, '') except locale.Error as e: logger.error("Could not set LC_ALL to ''", exc_info=e) else: log_locale('After LC_ALL defaults set') locale_startup = locale.getlocale(locale.LC_CTYPE) logger.debug(f'Locale LC_CTYPE: {locale_startup}') # Older Windows Versions and builds have issues with UTF-8, so only # even attempt this where we think it will be safe. if sys.platform == 'win32': windows_ver = sys.getwindowsversion() # # Windows 19, 1903 was build 18362 if ( sys.platform != 'win32' or ( windows_ver.major == 10 and windows_ver.build >= 18362 ) or windows_ver.major > 10 # Paranoid future check ): # Set that same language, but utf8 encoding (it was probably cp1252 # or equivalent for other languages). # UTF-8, not utf8: try: # locale_startup[0] is the 'language' portion locale.setlocale(locale.LC_ALL, (locale_startup[0], 'UTF-8')) except locale.Error: logger.exception(f"Could not set LC_ALL to ('{locale_startup[0]}', 'UTF_8')") except Exception: logger.exception( f"Exception other than locale.Error on setting LC_ALL=('{locale_startup[0]}', 'UTF_8')") else: log_locale('After switching to UTF-8 encoding (same language)') # TODO: unittests in place of these # logger.debug('Test from __main__') # test_logging() class A(object): class B(object): def __init__(self): logger.debug('A call from A.B.__init__') self.__test() _ = self.test_prop def __test(self): logger.debug("A call from A.B.__test") @property def test_prop(self): logger.debug("test log from property") return "Test property is testy" # abinit = A.B() # Plain, not via `logger` print(f'{applongname} {appversion}') Translations.install(config.get('language') or None) # Can generate errors so wait til log set up root = tk.Tk(className=appname.lower()) # UI Scaling """ We scale the UI relative to what we find tk-scaling is on startup. """ ui_scale = config.getint('ui_scale') # NB: This *also* catches a literal 0 value to re-set to the default 100 if not ui_scale: ui_scale = 100 config.set('ui_scale', ui_scale) theme.default_ui_scale = root.tk.call('tk', 'scaling') logger.trace(f'Default tk scaling = {theme.default_ui_scale}') theme.startup_ui_scale = ui_scale root.tk.call('tk', 'scaling', theme.default_ui_scale * float(ui_scale) / 100.0) app = AppWindow(root) def messagebox_not_py3(): plugins_not_py3_last = config.getint('plugins_not_py3_last') or 0 if (plugins_not_py3_last + 86400) < int(time()) and len(plug.PLUGINS_not_py3): # Yes, this is horribly hacky so as to be sure we match the key # that we told Translators to use. popup_text = "One or more of your enabled plugins do not yet have support for Python 3.x. Please see the " \ "list on the '{PLUGINS}' tab of '{FILE}' > '{SETTINGS}'. You should check if there is an " \ "updated version available, else alert the developer that they need to update the code for " \ "Python 3.x.\r\n\r\nYou can disable a plugin by renaming its folder to have '{DISABLED}' on " \ "the end of the name." popup_text = popup_text.replace('\n', '\\n') popup_text = popup_text.replace('\r', '\\r') # Now the string should match, so try translation popup_text = _(popup_text) # And substitute in the other words. popup_text = popup_text.format(PLUGINS=_('Plugins'), FILE=_('File'), SETTINGS=_('Settings'), DISABLED='.disabled') # And now we do need these to be actual \r\n popup_text = popup_text.replace('\\n', '\n') popup_text = popup_text.replace('\\r', '\r') tk.messagebox.showinfo( _('EDMC: Plugins Without Python 3.x Support'), popup_text ) config.set('plugins_not_py3_last', int(time())) root.after(0, messagebox_not_py3) root.mainloop() logger.info('Exiting')