#!/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.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 from journal_lock import JournalLock, JournalLockResult 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' ) parser.add_argument('edmc', help='Callback from Frontier Auth', nargs='*' ) 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 handle_edmc_callback_or_foregrounding(): # noqa: CCR001 """Handle any edmc:// auth callback, else foreground existing window.""" logger.trace('Begin...') if platform == 'win32': # If *this* instance hasn't locked, then another already has and we # now need to do the edmc:// checks for auth callback if locked != JournalLockResult.LOCKED: 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): logger.debug('Invoked with edmc:// protocol handler arg') 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 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_lock = JournalLock() locked = journal_lock.obtain_lock() handle_edmc_callback_or_foregrounding() if locked == JournalLockResult.ALREADY_LOCKED: # 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 if not args.edmc: 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')) # Update Journal lock if needs be. journal_lock.update_lock(self.w) # (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 'commander' not in data: # This can happen with EGS Auth if no commander created yet self.status['text'] = _('CAPI: No commander data returned') elif 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(dict(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): logger.debug('Received "<>') 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(dict(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')