#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Entry point for the main GUI application.""" import argparse import html import json import locale import pathlib import re import sys # import threading import webbrowser from builtins import object, str from os import chdir, environ from os.path import dirname, join from sys import platform from time import localtime, strftime, time from typing import TYPE_CHECKING, Optional, Tuple # Have this as early as possible for people running EDMarketConnector.exe # from cmd.exe or a bat file or similar. Else they might not be in the correct # place for things like config.py reading .gitversion 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') else: # We still want to *try* to have CWD be where the main script is, even if # not frozen. chdir(pathlib.Path(__file__).parent) 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='wt', buffering=1) # TODO: Test: Make *sure* this redirect is working, else py2exe is going to cause an exit popup # These need to be after the stdout/err redirect because they will cause # logging to be set up. # isort: off import killswitch from config import appversion, appversion_nobuild, config, copyright # isort: on from companion import CAPIData, index_possibly_sparse_list 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( '--reset-ui', help='reset UI theme and transparency to defaults', 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' ) auth_options = parser.add_mutually_exclusive_group(required=False) auth_options.add_argument('--force-localserver-for-auth', help='Force EDMC to use a localhost webserver for Frontier Auth callback', action='store_true' ) auth_options.add_argument('--force-edmc-protocol', help='Force use of the edmc:// protocol handler. Error if not on Windows', 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() if args.force_edmc_protocol: if sys.platform == 'win32': config.set_auth_force_edmc_protocol() else: print("--force-edmc-protocol is only valid on Windows") parser.print_help() exit(1) def handle_edmc_callback_or_foregrounding() -> None: # 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: int) -> Optional[str]: 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): # noqa: CCR001 """ 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 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 # type: ignore # noqa: F401 # Needed to update mypy import update # from infi.systray import SysTrayIcon # isort: on def _(x: str) -> str: """Fake the l10n translation functions for typing.""" return x 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 edmc_data import ship_name_map 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): """Define the main application window.""" # Tkinter Event types EVENT_KEYPRESS = 2 EVENT_BUTTON = 4 EVENT_VIRTUAL = 35 PADX = 5 def __init__(self, master: tk.Tk): # noqa: C901, CCR001 # TODO - can possibly factor something out self.holdofftime = config.get_int('querytime', default=0) + companion.holdoff self.w = master self.w.title(applongname) self.w.rowconfigure(0, weight=1) self.w.columnconfigure(0, weight=1) self.prefsdialog = None # if platform == 'win32': # from infi.systray import SysTrayIcon # def open_window(systray: 'SysTrayIcon') -> None: # self.w.deiconify() # menu_options = (("Open", None, open_window),) # # Method associated with on_quit is called whenever the systray is closing # self.systray = SysTrayIcon("EDMarketConnector.ico", applongname, menu_options, on_quit=self.exit_tray) # self.systray.start() 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_path, 'EDMarketConnector.png'))) # TODO: Export to files and merge from them in future ? 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.cmdr = tk.Label(frame, compound=tk.RIGHT, anchor=tk.W, name='cmdr') self.ship_label = tk.Label(frame) self.ship = HyperlinkLabel(frame, compound=tk.RIGHT, url=self.shipyard_url, name='ship') self.suit_label = tk.Label(frame) self.suit = tk.Label(frame, compound=tk.RIGHT, anchor=tk.W, name='suit') self.system_label = tk.Label(frame) self.system = HyperlinkLabel(frame, compound=tk.RIGHT, url=self.system_url, popup_copy=True, name='system') self.station_label = tk.Label(frame) self.station = HyperlinkLabel(frame, compound=tk.RIGHT, url=self.station_url, name='station') # system and station text is set/updated by the 'provider' plugins # eddb, edsm and inara. Look for: # # parent.children['system'] / parent.children['station'] ui_row = 1 self.cmdr_label.grid(row=ui_row, column=0, sticky=tk.W) self.cmdr.grid(row=ui_row, column=1, sticky=tk.EW) ui_row += 1 self.ship_label.grid(row=ui_row, column=0, sticky=tk.W) self.ship.grid(row=ui_row, column=1, sticky=tk.EW) ui_row += 1 self.suit_grid_row = ui_row self.suit_shown = False ui_row += 1 self.system_label.grid(row=ui_row, column=0, sticky=tk.W) self.system.grid(row=ui_row, column=1, sticky=tk.EW) ui_row += 1 self.station_label.grid(row=ui_row, column=0, sticky=tk.W) self.station.grid(row=ui_row, column=1, sticky=tk.EW) ui_row += 1 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: ui_row = frame.grid_size()[1] appitem[0].grid(row=ui_row, column=0, sticky=tk.W) appitem[1].grid(row=ui_row, column=1, sticky=tk.EW) else: appitem.grid(columnspan=2, sticky=tk.EW) # LANG: 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) ui_row = frame.grid_size()[1] self.button.grid(row=ui_row, columnspan=2, sticky=tk.NSEW) self.theme_button.grid(row=ui_row, columnspan=2, sticky=tk.NSEW) theme.register_alternate((self.button, self.theme_button, self.theme_button), {'row': ui_row, 'columnspan': 2, 'sticky': tk.NSEW}) 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=self.PADX, pady=(platform != 'win32' or isinstance(child, tk.Frame)) and 2 or 0) # The type needs defining for adding the menu entry, but won't be # properly set until later self.updater: update.Updater = None 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.w, self.status)) 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) # type: ignore self.file_menu.add_command(command=lambda: stats.StatsDialog(self.w, self.status)) 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) # type: ignore 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) # type: ignore 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=bool(config.get_int('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: Tuple[Optional[int], Optional[int]] = (None, 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=self.PADX, 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=self.PADX, 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_str('geometry'): match = re.match(r'\+([\-\d]+)\+([\-\d]+)', config.get_str('geometry')) if match: if platform == 'darwin': # http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7 if int(match.group(2)) >= 0: self.w.geometry(config.get_str('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_str('geometry')) else: self.w.geometry(config.get_str('geometry')) self.w.attributes('-topmost', config.get_int('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 # Migration from <= 3.30 for username in config.get_list('fdev_usernames', default=[]): config.delete_password(username) config.delete('fdev_usernames', suppress=True) config.delete('username', suppress=True) config.delete('password', suppress=True) config.delete('logdir', suppress=True) self.postprefs(False) # Companion login happens in callback from monitor self.toggle_suit_row(visible=False) def update_suit_text(self) -> None: """Update the suit text for current type and loadout.""" if not monitor.state['Odyssey']: # Odyssey not detected, no text should be set so it will hide self.suit['text'] = '' return if (suit := monitor.state.get('SuitCurrent')) is None: self.suit['text'] = f'<{_("Unknown")}>' # LANG: Unknown suit return suitname = suit['edmcName'] if (suitloadout := monitor.state.get('SuitLoadoutCurrent')) is None: self.suit['text'] = '' return loadout_name = suitloadout['name'] self.suit['text'] = f'{suitname} ({loadout_name})' def suit_show_if_set(self) -> None: """Show UI Suit row if we have data, else hide.""" if self.suit['text'] != '': self.toggle_suit_row(visible=True) else: self.toggle_suit_row(visible=False) def toggle_suit_row(self, visible: Optional[bool] = None) -> None: """ Toggle the visibility of the 'Suit' row. :param visible: Force visibility to this. """ if visible is True: self.suit_shown = False elif visible is False: self.suit_shown = True if not self.suit_shown: if platform != 'win32': pady = 2 else: pady = 0 self.suit_label.grid(row=self.suit_grid_row, column=0, sticky=tk.W, padx=self.PADX, pady=pady) self.suit.grid(row=self.suit_grid_row, column=1, sticky=tk.EW, padx=self.PADX, pady=pady) self.suit_shown = True else: # Hide the Suit row self.suit_label.grid_forget() self.suit.grid_forget() self.suit_shown = False def postprefs(self, dologin: bool = True): """Perform necessary actions after the Preferences dialog is applied.""" 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.get_int('hotkey_code'), config.get_int('hotkey_mods')) # Update Journal lock if needs be. journal_lock.update_lock(self.w) # (Re-)install log monitoring if not monitor.start(self.w): # LANG: ED Journal file location appears to be in error self.status['text'] = _('Error: Check E:D journal file location') if dologin and monitor.cmdr: self.login() # Login if not already logged in with this Cmdr def set_labels(self): """Set main window labels, e.g. after language change.""" self.cmdr_label['text'] = _('Cmdr') + ':' # LANG: Main window # LANG: 'Ship' or multi-crew role label in main window, as applicable self.ship_label['text'] = (monitor.state['Captain'] and _('Role') or _('Ship')) + ':' # Main window self.suit_label['text'] = _('Suit') + ':' # LANG: Main window self.system_label['text'] = _('System') + ':' # LANG: Main window self.station_label['text'] = _('Station') + ':' # LANG: Main window self.button['text'] = self.theme_button['text'] = _('Update') # LANG: Update button in main window if platform == 'darwin': self.menubar.entryconfigure(1, label=_('File')) # LANG: Menu title on OSX self.menubar.entryconfigure(2, label=_('Edit')) # LANG: Menu title on OSX self.menubar.entryconfigure(3, label=_('View')) # LANG: Menu title on OSX self.menubar.entryconfigure(4, label=_('Window')) # LANG: Menu title on OSX self.menubar.entryconfigure(5, label=_('Help')) # LANG: Menu title on OSX self.system_menu.entryconfigure(0, label=_("About {APP}").format( APP=applongname)) # LANG: App menu entry on OSX self.system_menu.entryconfigure(1, label=_("Check for Updates...")) # LANG: Menu item self.file_menu.entryconfigure(0, label=_('Save Raw Data...')) # LANG: Menu item self.view_menu.entryconfigure(0, label=_('Status')) # LANG: Menu item self.help_menu.entryconfigure(1, label=_('Privacy Policy')) # LANG: Help menu item self.help_menu.entryconfigure(2, label=_('Release Notes')) # LANG: Help menu item else: self.menubar.entryconfigure(1, label=_('File')) # LANG: Menu title self.menubar.entryconfigure(2, label=_('Edit')) # LANG: Menu title self.menubar.entryconfigure(3, label=_('Help')) # LANG: Menu title self.theme_file_menu['text'] = _('File') # LANG: Menu title self.theme_edit_menu['text'] = _('Edit') # LANG: Menu title self.theme_help_menu['text'] = _('Help') # LANG: Menu title # File menu self.file_menu.entryconfigure(0, label=_('Status')) # LANG: Menu item self.file_menu.entryconfigure(1, label=_('Save Raw Data...')) # LANG: Menu item self.file_menu.entryconfigure(2, label=_('Settings')) # LANG: Item in the File menu on Windows self.file_menu.entryconfigure(4, label=_('Exit')) # LANG: Item in the File menu on Windows # Help menu self.help_menu.entryconfigure(0, label=_('Documentation')) # LANG: Help menu item self.help_menu.entryconfigure(1, label=_('Privacy Policy')) # LANG: Help menu item self.help_menu.entryconfigure(2, label=_('Release Notes')) # LANG: Help menu item self.help_menu.entryconfigure(3, label=_('Check for Updates...')) # LANG: Menu item self.help_menu.entryconfigure(4, label=_("About {APP}").format(APP=applongname)) # LANG: App menu entry # Edit menu self.edit_menu.entryconfigure(0, label=_('Copy')) # LANG: As in Copy and Paste def login(self): """Initiate CAPI/Frontier login and set other necessary state.""" 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): # LANG: 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 export_market_data(self, data: CAPIData) -> bool: # noqa: CCR001 """ Export CAPI market data. :return: True if all OK, else False to trigger play_bad in caller. """ if config.get_int('output') & (config.OUT_STATION_ANY): if not data['commander'].get('docked') and not monitor.state['OnFoot']: 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 # LANG: Player is not docked at a station, when we expect them to be self.status['text'] = _("You're not docked at a station!") return False # Ignore possibly missing shipyard info elif (config.get_int('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.get_int('output') & (config.OUT_MKT_CSV | config.OUT_MKT_TD): # Fixup anomalies in the commodity data fixed = companion.fixup(data) if config.get_int('output') & config.OUT_MKT_CSV: commodity.export(fixed, COMMODITY_CSV) if config.get_int('output') & config.OUT_MKT_TD: td.export(fixed) return True def getandsend(self, event=None, retrying: bool = False): # noqa: C901, CCR001 """ Perform CAPI data retrieval and associated actions. This can be triggered by hitting the main UI 'Update' button, automatically on docking, or due to a retry. """ auto_update = not event play_sound = (auto_update or int(event.type) == self.EVENT_VIRTUAL) and not config.get_int('hotkey_mute') play_bad = False err: Optional[str] = None 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 if play_sound and (self.holdofftime - time()) < companion.holdoff * 0.75: self.status['text'] = '' 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 # LANG: No data was returned for the commander from the Frontier CAPI err = self.status['text'] = _('CAPI: No commander data returned') elif not data.get('commander', {}).get('name'): # LANG: We didn't have the commander name when we should have err = 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'))): # LANG: We don't know where the commander is, when we should err = self.status['text'] = _("Where are you?!") # Shouldn't happen elif not data.get('ship', {}).get('name') or not data.get('ship', {}).get('modules'): # LANG: We don't know what ship the commander is in, when we should err = self.status['text'] = _("What are you flying?!") # Shouldn't happen elif monitor.cmdr and data['commander']['name'] != monitor.cmdr: # Companion API Commander doesn't match Journal raise companion.CmdrError() elif auto_update and not monitor.state['OnFoot'] and not data['commander'].get('docked'): # auto update is only when just docked raise companion.ServerLagging() elif data['lastSystem']['name'] != monitor.system: # CAPI system must match last journal one raise companion.ServerLagging() elif data['lastStarport']['name'] != monitor.station: if monitor.state['OnFoot'] and monitor.station: raise companion.ServerLagging() else: last_station = None if data['commander']['docked']: last_station = data['lastStarport']['name'] if last_station != monitor.station: # CAPI lastStarport must match raise companion.ServerLagging() self.holdofftime = querytime + companion.holdoff elif not monitor.state['OnFoot'] and data['ship']['id'] != monitor.state['ShipID']: # CAPI ship must match raise companion.ServerLagging() elif not monitor.state['OnFoot'] and data['ship']['name'].lower() != monitor.state['ShipType']: # CAPI ship type must match raise companion.ServerLagging() else: if __debug__: # Recording companion.session.dump_capi_data(data) if not monitor.state['ShipType']: # Started game in SRV or fighter self.ship['text'] = ship_name_map.get(data['ship']['name'].lower(), data['ship']['name']) monitor.state['ShipID'] = data['ship']['id'] monitor.state['ShipType'] = data['ship']['name'].lower() if not monitor.state['Modules']: self.ship.configure(state=tk.DISABLED) # We might have disabled this in the conditional above. if monitor.state['Modules']: self.ship.configure(state=True) if monitor.state.get('SuitCurrent') is not None: if (loadout := data.get('loadout')) is not None: if (suit := loadout.get('suit')) is not None: if (suitname := suit.get('edmcName')) is not None: # We've been paranoid about loadout->suit->suitname, now just assume loadouts is there loadout_name = index_possibly_sparse_list( data['loadouts'], loadout['loadoutSlotId'] )['name'] self.suit['text'] = f'{suitname} ({loadout_name})' self.suit_show_if_set() 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 not self.export_market_data(data): err = 'Error: Exporting Market data' play_bad = True self.holdofftime = querytime + companion.holdoff # Companion API problem except companion.ServerLagging as e: err = str(e) if retrying: self.status['text'] = err 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 err = 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) err = self.status['text'] = str(e) play_bad = True if not err: # not self.status['text']: # no errors # LANG: Time when we last obtained Frontier CAPI data self.status['text'] = strftime(_('Last updated at %H:%M:%S'), localtime(querytime)) if play_sound and play_bad: hotkeymgr.play_bad() self.update_suit_text() self.suit_show_if_set() self.cooldown() def journal_event(self, event): # noqa: C901, CCR001 # Currently not easily broken up. """ Handle a Journal event passed through event queue from monitor.py. :param event: string JSON data of the event :return: """ def crewroletext(role: str) -> str: """ Return translated crew role. Needs to be dynamic to allow for changing language. """ return { None: '', 'Idle': '', 'FighterCon': _('Fighter'), # LANG: Multicrew role 'FireCon': _('Gunner'), # LANG: Multicrew role 'FlightCon': _('Helm'), # LANG: Multicrew role }.get(role, role) if monitor.thread is None: logger.debug('monitor.thread is None, assuming shutdown and returning') return while not monitor.event_queue.empty(): entry = monitor.get_entry() if not entry: # This is expected due to some monitor.py code that appends `None` # 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') + ':' # LANG: 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') + ':' # LANG: Main window # TODO: Show something else when on_foot if monitor.state['ShipName']: ship_text = monitor.state['ShipName'] else: ship_text = ship_name_map.get(monitor.state['ShipType'], monitor.state['ShipType']) if not ship_text: ship_text = '' # Ensure the ship type/name text is clickable, if it should be. if monitor.state['Modules']: ship_state = True else: ship_state = tk.DISABLED self.ship.configure(text=ship_text, url=self.shipyard_url, state=ship_state) else: self.cmdr['text'] = '' self.ship_label['text'] = _('Ship') + ':' # LANG: Main window self.ship['text'] = '' if monitor.cmdr and monitor.is_beta: self.cmdr['text'] += ' (beta)' self.update_suit_text() self.suit_show_if_set() 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_list('cmdrs') or monitor.cmdr not in config.get_list('cmdrs'): config.set('cmdrs', config.get_list('cmdrs', default=[]) + [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.get_int('disable_autoappupdatecheckingame') and 1: self.updater.setAutomaticUpdatesCheck(False) logger.info('Monitor: Disable WinSparkle automatic update checks') # Can't 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.get_int('output') & config.OUT_SHIP: monitor.export_ship() 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.get_int('hotkey_mute'): hotkeymgr.play_bad() auto_update = False # Only if auth callback is not pending if companion.session.state != companion.Session.STATE_AUTH: # Only if configured to do so if (not config.get_int('output') & config.OUT_MKT_MANUAL and config.get_int('output') & config.OUT_STATION_ANY): if entry['event'] in ('StartUp', 'Location', 'Docked') and monitor.station: # TODO: Can you log out in a docked Taxi and then back in to # the taxi, so 'Location' should be covered here too ? if entry['event'] == 'Docked' and entry.get('Taxi'): # In Odyssey there's a 'Docked' event for an Apex taxi, # but the CAPI data isn't updated until you Disembark. auto_update = False else: auto_update = True # In Odyssey if you are in a Taxi the `Docked` event for it is before # the CAPI data is updated, but CAPI *is* updated after you `Disembark`. elif entry['event'] == 'Disembark' and entry.get('Taxi') and entry.get('OnStation'): auto_update = True if auto_update: 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') def auth(self, event=None) -> None: """ Handle Frontier auth callback. This is the callback function for the CompanionAuthEvent Tk event. It is triggered by the event() function of class GenericProtocolHandler in protocol.py. """ try: companion.session.auth_callback() # LANG: 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() def dashboard_event(self, event) -> None: """ Handle DashBoardEvent tk event. Event is sent by code in dashboard.py. """ if not dashboard.status: return entry = dashboard.status # 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.get_int('hotkey_mute'): hotkeymgr.play_bad() def plugin_error(self, event=None) -> None: """Display asynchronous error from plugin.""" if plug.last_error.get('msg'): self.status['text'] = plug.last_error['msg'] self.w.update_idletasks() if not config.get_int('hotkey_mute'): hotkeymgr.play_bad() def shipyard_url(self, shipname: str) -> str: """Despatch a ship URL to the configured handler.""" if not (loadout := monitor.ship()): logger.warning('No ship loadout, aborting.') return '' if not bool(config.get_int("use_alt_shipyard_open")): return plug.invoke(config.get_str('shipyard_provider'), 'EDSY', 'shipyard_url', loadout, monitor.is_beta) # Avoid file length limits if possible provider = config.get_str('shipyard_provider', default='EDSY') target = plug.invoke(provider, 'EDSY', 'shipyard_url', loadout, monitor.is_beta) file_name = join(config.app_dir_path, "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: str) -> str: """Despatch a system URL to the configured handler.""" return plug.invoke(config.get_str('system_provider'), 'EDSM', 'system_url', monitor.system) def station_url(self, station: str) -> str: """Despatch a station URL to the configured handler.""" return plug.invoke(config.get_str('station_provider'), 'eddb', 'station_url', monitor.system, monitor.station) def cooldown(self) -> None: """Display and update the cooldown timer for 'Update' button.""" 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') # LANG: 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) -> None: """Set main window 'on top' state as appropriate.""" config.set('always_ontop', self.always_ontop.get()) self.w.wm_attributes('-topmost', self.always_ontop.get()) def copy(self, event=None) -> None: """Copy system, and possible station, name to clipboard.""" 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) -> None: """Open Wiki Help page in browser.""" webbrowser.open('https://github.com/EDCD/EDMarketConnector/wiki') def help_privacy(self, event=None) -> None: """Open Wiki Privacy page in browser.""" webbrowser.open('https://github.com/EDCD/EDMarketConnector/wiki/Privacy-Policy') def help_releases(self, event=None) -> None: """Open Releases page in browser.""" webbrowser.open('https://github.com/EDCD/EDMarketConnector/releases') class HelpAbout(tk.Toplevel): """The applications Help > About popup.""" showing = False def __init__(self, parent: tk.Tk): 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) -> None: """Close the window.""" self._destroy() def _destroy(self) -> None: """Set parent window's topmost appropriately as we close.""" self.parent.wm_attributes('-topmost', config.get_int('always_ontop') and 1 or 0) self.destroy() self.__class__.showing = False def save_raw(self) -> None: # noqa: CCR001 # Not easily broken up. """Save newly acquired CAPI data in the configured file.""" self.status['text'] = _('Fetching data...') self.w.update_idletasks() try: data: CAPIData = 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_str('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 exit_tray(self, systray: 'SysTrayIcon') -> None: # """Tray icon is shutting down.""" # exit_thread = threading.Thread(target=self.onexit) # exit_thread.setDaemon(True) # exit_thread.start() def onexit(self, event=None) -> None: """Application shutdown procedure.""" # if platform == 'win32': # shutdown_thread = threading.Thread(target=self.systray.shutdown) # shutdown_thread.setDaemon(True) # shutdown_thread.start() 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. # LANG: The application is 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) -> None: """Continued handling of window drag.""" if self.drag_offset[0]: 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) -> None: """Handle end of window dragging.""" self.drag_offset = (None, None) def oniconify(self, event=None) -> None: """Handle minimization of the application.""" 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 # TODO: Confirm this is unused and remove. def onmap(self, event=None) -> None: """Perform a now unused function.""" if event.widget == self.w: theme.apply(self.w) def onenter(self, event=None) -> None: """Handle when our window gains focus.""" # TODO: This assumes that 1) transparent is at least 2, 2) there are # no new themes added after that. if config.get_int('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) -> None: """Handle when our window loses focus.""" # TODO: This assumes that 1) transparent is at least 2, 2) there are # no new themes added after that. if config.get_int('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() -> None: """Simple test of top level logging.""" logger.debug('Test from EDMarketConnector.py top-level test_logging()') def log_locale(prefix: str) -> None: """Log all of the current local settings.""" 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)}''' ) def setup_killswitches(): """Download and setup the main killswitch list.""" logger.debug('fetching killswitches...') killswitch.setup_main_list() def show_killswitch_poppup(root=None): """Show a warning popup if there are any killswitches that match the current version.""" if len(kills := killswitch.kills_for_version()) == 0: return text = ( "Some EDMC Features have been disabled due to known issues.\n" "Please update EDMC as soon as possible to resolve any issues.\n" ) tl = tk.Toplevel(root) tl.wm_attributes('-topmost', True) tl.geometry(f'+{root.winfo_rootx()}+{root.winfo_rooty()}') tl.columnconfigure(1, weight=1) tl.title("EDMC Features have been disabled") frame = tk.Frame(tl) frame.grid() t = tk.Label(frame, text=text) t.grid(columnspan=2) idx = 1 for version in kills: tk.Label(frame, text=f'Version: {version.version}').grid(row=idx, sticky=tk.W) idx += 1 for id, reason in version.kills.items(): tk.Label(frame, text=id).grid(column=0, row=idx, sticky=tk.W, padx=(10, 0)) tk.Label(frame, text=reason).grid(column=1, row=idx, sticky=tk.E, padx=(0, 10)) idx += 1 idx += 1 ok_button = tk.Button(frame, text="ok", command=tl.destroy) ok_button.grid(columnspan=2, sticky=tk.EW) theme.apply(tl) # 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}''' ) if args.reset_ui: config.set('theme', 0) # 'Default' theme uses ID 0 config.set('ui_transparency', 100) # 100 is completely opaque config.delete('font') config.delete('font_size') logger.info('reset theme, font, font size, and transparency to default.') # 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. # # Note that this locale magic is partially done in l10n.py as well. So # removing or modifying this may or may not have the desired effect. 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): """Simple top-level class.""" class B(object): """Simple second-level class.""" 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): """Test property.""" 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_str('language')) # Can generate errors so wait til log set up setup_killswitches() root = tk.Tk(className=appname.lower()) if sys.platform != 'win32' and ((f := config.get_str('font')) is not None or f != ''): size = config.get_int('font_size', default=-1) if size == -1: size = 10 logger.info(f'Overriding tkinter default font to {f!r} at size {size}') tk.font.nametofont('TkDefaultFont').configure(family=f, size=size) # UI Scaling """ We scale the UI relative to what we find tk-scaling is on startup. """ ui_scale = config.get_int('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(): """Display message about plugins not updated for Python 3.x.""" plugins_not_py3_last = config.get_int('plugins_not_py3_last', default=0) if (plugins_not_py3_last + 86400) < int(time()) and len(plug.PLUGINS_not_py3): 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 " r"Python 3.x.\r\n\r\nYou can disable a plugin by renaming its folder to have '{DISABLED}' on " "the end of the name." ) # Substitute in the other words. # LANG: words for use in python 2 plugin error 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())) # UI Transparency ui_transparency = config.get_int('ui_transparency') if ui_transparency == 0: ui_transparency = 100 root.wm_attributes('-alpha', ui_transparency / 100) root.after(0, messagebox_not_py3) root.after(1, show_killswitch_poppup, root) root.mainloop() logger.info('Exiting')