mirror of
https://github.com/EDCD/EDMarketConnector.git
synced 2025-04-04 19:40:02 +03:00
2334 lines
102 KiB
Python
Executable File
2334 lines
102 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
EDMarketConnector.py - Entry point for the GUI.
|
|
|
|
Copyright (c) EDCD, All Rights Reserved
|
|
Licensed under the GNU General Public License.
|
|
See LICENSE file.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import html
|
|
import locale
|
|
import os
|
|
import pathlib
|
|
import queue
|
|
import re
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import webbrowser
|
|
from os import chdir, environ
|
|
from time import localtime, strftime, time
|
|
from typing import TYPE_CHECKING, Any, Literal
|
|
from constants import applongname, appname, protocolhandler_redirect
|
|
|
|
# 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 sys.platform == 'win32':
|
|
os.chdir(pathlib.Path(sys.path[0]).parent)
|
|
# Allow executable to be invoked from any cwd
|
|
environ['TCL_LIBRARY'] = str(pathlib.Path(sys.path[0]).parent / 'lib' / 'tcl')
|
|
environ['TK_LIBRARY'] = str(pathlib.Path(sys.path[0]).parent / '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)
|
|
|
|
# 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):
|
|
from config import config
|
|
# By default py2exe tries to write log to dirname(sys.executable) which fails when installed
|
|
# unbuffered not allowed for text in python3, so use `1 for line buffering
|
|
log_file_path = pathlib.Path(config.app_dir_path / 'logs')
|
|
log_file_path.mkdir(exist_ok=True)
|
|
log_file_path /= f'{appname}.log'
|
|
|
|
sys.stdout = sys.stderr = open(log_file_path, mode='wt', buffering=1) # Do NOT use WITH here.
|
|
# 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 EDMCLogging import edmclogger, logger, logging
|
|
from journal_lock import JournalLock, JournalLockResult
|
|
from update import check_for_fdev_updates
|
|
|
|
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 and Inara.cz."
|
|
)
|
|
|
|
###########################################################################
|
|
# Permanent config changes
|
|
###########################################################################
|
|
parser.add_argument(
|
|
'--reset-ui',
|
|
help='Reset UI theme, transparency, font, font size, ui scale, and ui geometry to default',
|
|
action='store_true'
|
|
)
|
|
###########################################################################
|
|
|
|
###########################################################################
|
|
# User 'utility' args
|
|
###########################################################################
|
|
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('--start_min',
|
|
help="Start the application minimized",
|
|
action="store_true"
|
|
)
|
|
###########################################################################
|
|
|
|
###########################################################################
|
|
# Adjust logging
|
|
###########################################################################
|
|
parser.add_argument(
|
|
'--trace',
|
|
help='Set the Debug logging loglevel to TRACE',
|
|
action='store_true',
|
|
)
|
|
|
|
parser.add_argument(
|
|
'--trace-on',
|
|
help='Mark the selected trace logging as active. "*" or "all" is equivalent to --trace-all',
|
|
action='append',
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--trace-all",
|
|
help='Force trace level logging, with all possible --trace-on values active.',
|
|
action='store_true'
|
|
)
|
|
|
|
parser.add_argument(
|
|
'--debug-sender',
|
|
help='Mark the selected sender as in debug mode. This generally results in data being written to disk',
|
|
action='append',
|
|
)
|
|
###########################################################################
|
|
|
|
###########################################################################
|
|
# Frontier Auth
|
|
###########################################################################
|
|
parser.add_argument(
|
|
'--forget-frontier-auth',
|
|
help='resets all authentication tokens',
|
|
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='*'
|
|
)
|
|
###########################################################################
|
|
|
|
###########################################################################
|
|
# Developer 'utility' args
|
|
###########################################################################
|
|
parser.add_argument(
|
|
'--capi-pretend-down',
|
|
help='Force to raise ServerError on any CAPI query',
|
|
action='store_true'
|
|
)
|
|
|
|
parser.add_argument(
|
|
'--capi-use-debug-access-token',
|
|
help='Load a debug Access Token from disk (from config.app_dir_pathapp_dir_path / access_token.txt)',
|
|
action='store_true'
|
|
)
|
|
|
|
parser.add_argument(
|
|
'--eddn-url',
|
|
help='Specify an alternate EDDN upload URL',
|
|
)
|
|
|
|
parser.add_argument(
|
|
'--eddn-tracking-ui',
|
|
help='Have EDDN plugin show what it is tracking',
|
|
action='store_true',
|
|
)
|
|
|
|
parser.add_argument(
|
|
'--killswitches-file',
|
|
help='Specify a custom killswitches file',
|
|
)
|
|
###########################################################################
|
|
|
|
args: argparse.Namespace = parser.parse_args()
|
|
|
|
if args.capi_pretend_down:
|
|
import config as conf_module
|
|
logger.info('Pretending CAPI is down')
|
|
conf_module.capi_pretend_down = True
|
|
|
|
if args.capi_use_debug_access_token:
|
|
import config as conf_module
|
|
with open(conf_module.config.app_dir_path / 'access_token.txt', 'r') as at:
|
|
conf_module.capi_debug_access_token = at.readline().strip()
|
|
|
|
level_to_set: int | None = None
|
|
if args.trace or args.trace_on:
|
|
level_to_set = logging.TRACE # type: ignore # it exists
|
|
logger.info('Setting TRACE level debugging due to either --trace or a --trace-on')
|
|
|
|
if args.trace_all or (args.trace_on and ('*' in args.trace_on or 'all' in args.trace_on)):
|
|
level_to_set = logging.TRACE_ALL # type: ignore # it exists
|
|
logger.info('Setting TRACE_ALL level debugging due to either --trace-all or a --trace-on *|all')
|
|
|
|
if level_to_set is not None:
|
|
logger.setLevel(level_to_set)
|
|
edmclogger.set_channels_loglevel(level_to_set)
|
|
|
|
if args.force_localserver_for_auth:
|
|
config.set_auth_force_localserver()
|
|
|
|
if args.eddn_url:
|
|
config.set_eddn_url(args.eddn_url)
|
|
|
|
if args.eddn_tracking_ui:
|
|
config.set_eddn_tracking_ui()
|
|
|
|
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()
|
|
sys.exit(1)
|
|
|
|
if args.debug_sender and len(args.debug_sender) > 0:
|
|
import config as conf_module
|
|
import debug_webserver
|
|
from edmc_data import DEBUG_WEBSERVER_HOST, DEBUG_WEBSERVER_PORT
|
|
|
|
conf_module.debug_senders = [x.casefold() for x in args.debug_sender] # duplicate the list just in case
|
|
for d in conf_module.debug_senders:
|
|
logger.info(f'marked {d} for debug')
|
|
|
|
debug_webserver.run_listener(DEBUG_WEBSERVER_HOST, DEBUG_WEBSERVER_PORT)
|
|
|
|
if args.trace_on and len(args.trace_on) > 0:
|
|
import config as conf_module
|
|
|
|
conf_module.trace_on = [x.casefold() for x in args.trace_on] # duplicate the list just in case
|
|
for d in conf_module.trace_on:
|
|
logger.info(f'marked {d} for TRACE')
|
|
|
|
def handle_edmc_callback_or_foregrounding() -> None: # noqa: CCR001
|
|
"""Handle any edmc:// auth callback, else foreground an existing window."""
|
|
logger.trace_if('frontier-auth.windows', 'Begin...')
|
|
|
|
if sys.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:
|
|
from ctypes import windll, create_unicode_buffer, WINFUNCTYPE
|
|
from ctypes.wintypes import BOOL, HWND, LPARAM
|
|
import win32gui
|
|
import win32api
|
|
import win32con
|
|
|
|
GetProcessHandleFromHwnd = windll.oleacc.GetProcessHandleFromHwnd # noqa: N806
|
|
ShowWindowAsync = windll.user32.ShowWindowAsync # noqa: N806
|
|
|
|
COINIT_MULTITHREADED = 0 # noqa: N806,F841
|
|
COINIT_APARTMENTTHREADED = 0x2 # noqa: N806
|
|
COINIT_DISABLE_OLE1DDE = 0x4 # noqa: N806
|
|
CoInitializeEx = windll.ole32.CoInitializeEx # noqa: N806
|
|
|
|
def window_title(h: int) -> str | None:
|
|
if h:
|
|
return win32gui.GetWindowText(h)
|
|
return None
|
|
|
|
@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 = create_unicode_buffer(257)
|
|
# This conditional is exploded to make debugging slightly easier
|
|
if win32gui.GetClassName(window_handle):
|
|
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
|
|
win32gui.ShowWindow(window_handle, win32con.SW_RESTORE)
|
|
win32api.ShellExecute(0, None, sys.argv[1], None, None, win32con.SW_RESTORE)
|
|
|
|
else:
|
|
ShowWindowAsync(window_handle, win32con.SW_RESTORE)
|
|
win32gui.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: <https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-enumwindows>
|
|
win32gui.EnumWindows(enumwindowsproc, 0)
|
|
|
|
def already_running_popup():
|
|
"""Create the "already running" popup."""
|
|
from tkinter import messagebox
|
|
# Check for CL arg that suppresses this popup.
|
|
if args.suppress_dupe_process_popup:
|
|
sys.exit(0)
|
|
|
|
messagebox.showerror(title=appname, message="An EDMarketConnector process was already running, exiting.")
|
|
sys.exit(0)
|
|
|
|
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()
|
|
|
|
git_branch = ""
|
|
try:
|
|
git_cmd = subprocess.Popen('git branch --show-current'.split(),
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT
|
|
)
|
|
out, err = git_cmd.communicate()
|
|
git_branch = out.decode().strip()
|
|
|
|
except Exception:
|
|
pass
|
|
|
|
if (
|
|
git_branch == 'develop'
|
|
or (
|
|
git_branch == '' and '-alpha0' in str(appversion())
|
|
)
|
|
):
|
|
print("You're running in a DEVELOPMENT branch build. You might encounter bugs!")
|
|
|
|
|
|
# See EDMCLogging.py docs.
|
|
# isort: off
|
|
if TYPE_CHECKING:
|
|
from logging import TRACE # type: ignore # noqa: F401 # Needed to update mypy
|
|
|
|
if sys.platform == 'win32':
|
|
from simplesystray import SysTrayIcon
|
|
# isort: on
|
|
|
|
|
|
import tkinter as tk
|
|
import tkinter.filedialog
|
|
import tkinter.font
|
|
import tkinter.messagebox
|
|
from tkinter import ttk
|
|
import commodity
|
|
import plug
|
|
import prefs
|
|
import protocol
|
|
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 as tr
|
|
from monitor import monitor
|
|
from theme import theme
|
|
from ttkHyperlinkLabel import HyperlinkLabel, SHIPYARD_HTML_TEMPLATE
|
|
|
|
SERVER_RETRY = 5 # retry pause for Companion servers [s]
|
|
|
|
|
|
class AppWindow:
|
|
"""Define the main application window."""
|
|
|
|
_CAPI_RESPONSE_TK_EVENT_NAME = '<<CAPIResponse>>'
|
|
# 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.capi_query_holdoff_time = config.get_int('querytime', default=0) + companion.capi_query_cooldown
|
|
self.capi_fleetcarrier_query_holdoff_time = config.get_int('fleetcarrierquerytime', default=0) \
|
|
+ companion.capi_fleetcarrier_query_cooldown
|
|
|
|
self.w = master
|
|
self.w.title(applongname)
|
|
self.minimizing = False
|
|
self.w.rowconfigure(0, weight=1)
|
|
self.w.columnconfigure(0, weight=1)
|
|
|
|
# companion needs to be able to send <<CAPIResponse>> events
|
|
companion.session.set_tk_master(self.w)
|
|
|
|
self.prefsdialog = None
|
|
|
|
if sys.platform == 'win32':
|
|
from simplesystray import SysTrayIcon
|
|
|
|
def open_window(systray: 'SysTrayIcon', *args) -> 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 sys.platform == 'win32':
|
|
self.w.wm_iconbitmap(default='EDMarketConnector.ico')
|
|
|
|
else:
|
|
image_path = config.respath_path / 'io.edcd.EDMarketConnector.png'
|
|
self.w.tk.call('wm', 'iconphoto', self.w, '-default', tk.PhotoImage(file=image_path))
|
|
|
|
# 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, name='cmdr_label')
|
|
self.cmdr = tk.Label(frame, compound=tk.RIGHT, anchor=tk.W, name='cmdr')
|
|
self.ship_label = tk.Label(frame, name='ship_label')
|
|
self.ship = HyperlinkLabel(frame, compound=tk.RIGHT, url=self.shipyard_url, name='ship', popup_copy=True)
|
|
self.suit_label = tk.Label(frame, name='suit_label')
|
|
self.suit = tk.Label(frame, compound=tk.RIGHT, anchor=tk.W, name='suit')
|
|
self.system_label = tk.Label(frame, name='system_label')
|
|
self.system = HyperlinkLabel(frame, compound=tk.RIGHT, url=self.system_url, popup_copy=True, name='system')
|
|
self.station_label = tk.Label(frame, name='station_label')
|
|
self.station = HyperlinkLabel(frame, compound=tk.RIGHT, url=self.station_url, name='station', popup_copy=True)
|
|
# system and station text is set/updated by the 'provider' plugins
|
|
# edsm and inara. Look for:
|
|
#
|
|
# parent.nametowidget(f".{appname.lower()}.system")
|
|
# parent.nametowidget(f".{appname.lower()}.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
|
|
|
|
plugin_no = 0
|
|
for plugin in plug.PLUGINS:
|
|
# Per plugin separator
|
|
plugin_sep = tk.Frame(
|
|
frame, highlightthickness=1, name=f"plugin_hr_{plugin_no + 1}"
|
|
)
|
|
# Per plugin frame, for it to use as its parent for own widgets
|
|
plugin_frame = tk.Frame(
|
|
frame,
|
|
name=f"plugin_{plugin_no + 1}"
|
|
)
|
|
appitem = plugin.get_app(plugin_frame)
|
|
if appitem:
|
|
plugin_no += 1
|
|
plugin_sep.grid(columnspan=2, sticky=tk.EW)
|
|
ui_row = frame.grid_size()[1]
|
|
plugin_frame.grid(
|
|
row=ui_row, columnspan=2, sticky=tk.NSEW
|
|
)
|
|
plugin_frame.columnconfigure(1, weight=1)
|
|
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)
|
|
|
|
else:
|
|
# This plugin didn't provide any UI, so drop the frames
|
|
plugin_frame.destroy()
|
|
plugin_sep.destroy()
|
|
|
|
# LANG: Update button in main window
|
|
self.button = ttk.Button(
|
|
frame,
|
|
name='update_button',
|
|
text=tr.tl('Update'), # LANG: Main UI Update button
|
|
width=28,
|
|
default=tk.ACTIVE,
|
|
state=tk.DISABLED
|
|
)
|
|
self.theme_button = tk.Label(
|
|
frame,
|
|
name='themed_update_button',
|
|
width=28,
|
|
state=tk.DISABLED
|
|
)
|
|
|
|
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.button.bind('<Button-1>', self.capi_request_data)
|
|
theme.button_bind(self.theme_button, self.capi_request_data)
|
|
|
|
# Bottom 'status' line.
|
|
self.status = tk.Label(frame, name='status', anchor=tk.W)
|
|
self.status.grid(columnspan=2, sticky=tk.EW)
|
|
|
|
for child in frame.winfo_children():
|
|
child.grid_configure(padx=self.PADX, pady=(
|
|
sys.platform != 'win32' or isinstance(child, tk.Frame)) and 2 or 0)
|
|
|
|
self.menubar = tk.Menu()
|
|
|
|
# This used to be *after* the menu setup for some reason, but is testing
|
|
# as working (both internal and external) like this. -Ath
|
|
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)
|
|
self.updater.check_for_updates() # Sparkle / WinSparkle does this automatically for packaged apps
|
|
|
|
self.file_menu = self.view_menu = tk.Menu(self.menubar, tearoff=tk.FALSE)
|
|
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)
|
|
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) # Documentation
|
|
self.help_menu.add_command(command=self.help_troubleshooting) # Troubleshooting
|
|
self.help_menu.add_command(command=self.help_report_a_bug) # Report A Bug
|
|
self.help_menu.add_command(command=self.help_privacy) # Privacy Policy
|
|
self.help_menu.add_command(command=self.help_releases) # Release Notes
|
|
self.help_menu.add_command(command=lambda: self.updater.check_for_updates()) # Check for Updates...
|
|
# About E:D Market Connector
|
|
self.help_menu.add_command(command=lambda: not self.HelpAbout.showing and self.HelpAbout(self.w))
|
|
logfile_loc = pathlib.Path(config.app_dir_path / 'logs')
|
|
self.help_menu.add_command(command=lambda: prefs.open_folder(logfile_loc)) # Open Log Folder
|
|
self.help_menu.add_command(command=lambda: prefs.help_open_system_profiler(self)) # Open Log Folde
|
|
|
|
self.menubar.add_cascade(menu=self.help_menu)
|
|
if sys.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()
|
|
# LANG: Appearance - Label for checkbox to select if application always on top
|
|
self.system_menu.add_checkbutton(label=tr.tl('Always on top'),
|
|
variable=self.always_ontop,
|
|
command=self.ontop_changed) # Appearance setting
|
|
self.menubar.add_cascade(menu=self.system_menu)
|
|
self.w.bind('<Control-c>', self.copy)
|
|
|
|
# Bind to the Default theme minimise button
|
|
self.w.bind("<Unmap>", self.default_iconify)
|
|
|
|
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, name="alternate_menubar")
|
|
self.theme_menubar.columnconfigure(2, weight=1)
|
|
theme_titlebar = tk.Label(
|
|
self.theme_menubar,
|
|
name="alternate_titlebar",
|
|
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[int | None, int | None] = (None, None)
|
|
theme_titlebar.bind('<Button-1>', self.drag_start)
|
|
theme_titlebar.bind('<B1-Motion>', self.drag_continue)
|
|
theme_titlebar.bind('<ButtonRelease-1>', 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, name="blank_menubar")
|
|
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 sys.platform == 'win32':
|
|
# Check that the titlebar will be at least partly on screen
|
|
import win32api
|
|
import win32con
|
|
|
|
x = int(match.group(1)) + 16
|
|
y = int(match.group(2)) + 16
|
|
point = (x, y)
|
|
# https://msdn.microsoft.com/en-us/library/dd145064
|
|
if win32api.MonitorFromPoint(point, win32con.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('<Map>', self.onmap) # Special handling for overrideredict
|
|
self.w.bind('<Enter>', self.onenter) # Special handling for transparency
|
|
self.w.bind('<FocusIn>', self.onenter) # Special handling for transparency
|
|
self.w.bind('<Leave>', self.onleave) # Special handling for transparency
|
|
self.w.bind('<FocusOut>', self.onleave) # Special handling for transparency
|
|
self.w.bind('<Return>', self.capi_request_data)
|
|
self.w.bind('<KP_Enter>', self.capi_request_data)
|
|
self.w.bind_all('<<Invoke>>', self.capi_request_data) # Ask for CAPI queries to be performed
|
|
self.w.bind_all(self._CAPI_RESPONSE_TK_EVENT_NAME, self.capi_handle_response)
|
|
self.w.bind_all('<<JournalEvent>>', self.journal_event) # type: ignore # Journal monitoring
|
|
self.w.bind_all('<<DashboardEvent>>', self.dashboard_event) # Dashboard monitoring
|
|
self.w.bind_all('<<PluginError>>', self.plugin_error) # Statusbar
|
|
self.w.bind_all('<<CompanionAuthEvent>>', self.auth) # cAPI auth
|
|
self.w.bind_all('<<Quit>>', self.onexit) # Updater
|
|
|
|
# Check for Valid Providers
|
|
validate_providers()
|
|
if monitor.cmdr is None:
|
|
self.status['text'] = tr.tl("Awaiting Full CMDR Login") # LANG: Await Full CMDR Login to Game
|
|
|
|
# Start a protocol handler to handle cAPI registration. Requires main loop to be running.
|
|
self.w.after_idle(lambda: protocol.protocolhandler.start(self.w))
|
|
|
|
# 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)
|
|
if args.start_min:
|
|
logger.warning("Trying to start minimized")
|
|
if root.overrideredirect():
|
|
self.oniconify()
|
|
else:
|
|
self.w.wm_iconify()
|
|
|
|
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
|
|
|
|
suit = monitor.state.get('SuitCurrent')
|
|
if suit is None:
|
|
self.suit['text'] = f'<{tr.tl("Unknown")}>' # LANG: Unknown suit
|
|
return
|
|
|
|
suitname = suit['edmcName']
|
|
suitloadout = monitor.state.get('SuitLoadoutCurrent')
|
|
if suitloadout 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."""
|
|
self.toggle_suit_row(self.suit['text'] != '')
|
|
|
|
def toggle_suit_row(self, visible: bool | None = None) -> None:
|
|
"""
|
|
Toggle the visibility of the 'Suit' row.
|
|
|
|
:param visible: Force visibility to this.
|
|
"""
|
|
self.suit_shown = not visible
|
|
|
|
if not self.suit_shown:
|
|
pady = 2 if sys.platform != 'win32' else 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, **postargs):
|
|
"""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'] = tr.tl('Error: Check E:D journal file location')
|
|
|
|
if dologin and monitor.cmdr:
|
|
self.login() # Login if not already logged in with this Cmdr
|
|
|
|
if postargs.get('Update') and postargs.get('Track'):
|
|
track = postargs.get('Track')
|
|
# LANG: Inform the user the Update Track has changed
|
|
self.status['text'] = tr.tl('Update Track Changed to {TRACK}').format(TRACK=track)
|
|
self.updater.check_for_updates()
|
|
if track == "Stable":
|
|
# LANG: Inform the user the Update Track has changed
|
|
title = tr.tl('Update Track Changed to {TRACK}').format(TRACK=track)
|
|
update_msg = tr.tl( # LANG: Inform User of Beta -> Stable Transition Risks
|
|
'Update track changed to Stable from Beta. '
|
|
'You will no longer receive Beta updates. You will stay on your current Beta '
|
|
r'version until the next Stable release.\r\n\r\n'
|
|
'You can manually revert to the latest Stable version. To do so, you must download and install '
|
|
'the latest Stable version manually. Note that this may introduce bugs or break completely'
|
|
r' if downgrading between major versions with significant changes.\r\n\r\n'
|
|
'Do you want to open GitHub to download the latest release?'
|
|
)
|
|
update_msg = update_msg.replace('\\n', '\n')
|
|
update_msg = update_msg.replace('\\r', '\r')
|
|
stable_popup = tk.messagebox.askyesno(title=title, message=update_msg)
|
|
if stable_popup:
|
|
webbrowser.open("https://github.com/EDCD/eDMarketConnector/releases/latest")
|
|
if postargs.get('Restart_Req'):
|
|
# LANG: Text of Notification Popup for EDMC Restart
|
|
restart_msg = tr.tl('A restart of EDMC is required. EDMC will now restart.')
|
|
restart_box = tk.messagebox.Message(
|
|
title=tr.tl('Restart Required'), # LANG: Title of Notification Popup for EDMC Restart
|
|
message=restart_msg,
|
|
type=tk.messagebox.OK
|
|
)
|
|
restart_box.show()
|
|
if restart_box:
|
|
app.onexit(restart=True)
|
|
|
|
def set_labels(self):
|
|
"""Set main window labels, e.g. after language change."""
|
|
self.cmdr_label['text'] = tr.tl('Cmdr') + ':' # LANG: Label for commander name in main window
|
|
# LANG: 'Ship' or multi-crew role label in main window, as applicable
|
|
self.ship_label['text'] = (monitor.state['Captain'] and tr.tl('Role') or tr.tl('Ship')) + ':' # Main window
|
|
self.suit_label['text'] = tr.tl('Suit') + ':' # LANG: Label for 'Suit' line in main UI
|
|
self.system_label['text'] = tr.tl('System') + ':' # LANG: Label for 'System' line in main UI
|
|
self.station_label['text'] = tr.tl('Station') + ':' # LANG: Label for 'Station' line in main UI
|
|
self.button['text'] = self.theme_button['text'] = tr.tl('Update') # LANG: Update button in main window
|
|
self.menubar.entryconfigure(1, label=tr.tl('File')) # LANG: 'File' menu title
|
|
self.menubar.entryconfigure(2, label=tr.tl('Edit')) # LANG: 'Edit' menu title
|
|
self.menubar.entryconfigure(3, label=tr.tl('Help')) # LANG: 'Help' menu title
|
|
self.theme_file_menu['text'] = tr.tl('File') # LANG: 'File' menu title
|
|
self.theme_edit_menu['text'] = tr.tl('Edit') # LANG: 'Edit' menu title
|
|
self.theme_help_menu['text'] = tr.tl('Help') # LANG: 'Help' menu title
|
|
|
|
# File menu
|
|
self.file_menu.entryconfigure(0, label=tr.tl('Status')) # LANG: File > Status
|
|
self.file_menu.entryconfigure(1, label=tr.tl('Save Raw Data...')) # LANG: File > Save Raw Data...
|
|
self.file_menu.entryconfigure(2, label=tr.tl('Settings')) # LANG: File > Settings
|
|
self.file_menu.entryconfigure(4, label=tr.tl('Exit')) # LANG: File > Exit
|
|
|
|
# Help menu
|
|
self.help_menu.entryconfigure(0, label=tr.tl('Documentation')) # LANG: Help > Documentation
|
|
self.help_menu.entryconfigure(1, label=tr.tl('Troubleshooting')) # LANG: Help > Troubleshooting
|
|
self.help_menu.entryconfigure(2, label=tr.tl('Report A Bug')) # LANG: Help > Report A Bug
|
|
self.help_menu.entryconfigure(3, label=tr.tl('Privacy Policy')) # LANG: Help > Privacy Policy
|
|
self.help_menu.entryconfigure(4, label=tr.tl('Release Notes')) # LANG: Help > Release Notes
|
|
self.help_menu.entryconfigure(5, label=tr.tl('Check for Updates...')) # LANG: Help > Check for Updates...
|
|
self.help_menu.entryconfigure(6, label=tr.tl("About {APP}").format(APP=applongname)) # LANG: Help > About App
|
|
self.help_menu.entryconfigure(7, label=tr.tl('Open Log Folder')) # LANG: Help > Open Log Folder
|
|
self.help_menu.entryconfigure(8, label=tr.tl('Open System Profiler')) # LANG: Help > Open System Profiler
|
|
|
|
# Edit menu
|
|
self.edit_menu.entryconfigure(0, label=tr.tl('Copy')) # LANG: Label for 'Copy' as in 'Copy and Paste'
|
|
|
|
def login(self):
|
|
"""Initiate CAPI/Frontier login and set other necessary state."""
|
|
should_return: bool
|
|
new_data: dict[str, Any]
|
|
|
|
should_return, new_data = killswitch.check_killswitch('capi.auth', {})
|
|
if should_return:
|
|
logger.warning('capi.auth has been disabled via killswitch. Returning.')
|
|
# LANG: CAPI auth aborted because of killswitch
|
|
self.status['text'] = tr.tl('CAPI auth disabled by killswitch')
|
|
return
|
|
|
|
if not self.status['text']:
|
|
# LANG: Status - Attempting to get a Frontier Auth Access Token
|
|
self.status['text'] = tr.tl('Logging in...')
|
|
|
|
self.button['state'] = self.theme_button['state'] = tk.DISABLED
|
|
|
|
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'] = tr.tl('Authentication successful')
|
|
|
|
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.
|
|
"""
|
|
output_flags = config.get_int('output')
|
|
is_docked = data['commander'].get('docked')
|
|
has_commodities = data['lastStarport'].get('commodities')
|
|
has_modules = data['lastStarport'].get('modules')
|
|
commodities_flag = config.OUT_MKT_CSV | config.OUT_MKT_TD
|
|
|
|
if output_flags & config.OUT_STATION_ANY:
|
|
if not is_docked and not monitor.state['OnFoot']:
|
|
# 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._handle_status(tr.tl("You're not docked at a station!"))
|
|
return False
|
|
|
|
# Ignore possibly missing shipyard info
|
|
if output_flags & config.OUT_EDDN_SEND_STATION_DATA and not (has_commodities or has_modules):
|
|
# LANG: Status - Either no market or no modules data for station from Frontier CAPI
|
|
self._handle_status(tr.tl("Station doesn't have anything!"))
|
|
|
|
elif not has_commodities:
|
|
# LANG: Status - No station market data from Frontier CAPI
|
|
self._handle_status(tr.tl("Station doesn't have a market!"))
|
|
|
|
elif output_flags & commodities_flag:
|
|
# Fixup anomalies in the comodity data
|
|
fixed = companion.fixup(data)
|
|
if output_flags & config.OUT_MKT_CSV:
|
|
commodity.export(fixed, COMMODITY_CSV)
|
|
|
|
if output_flags & config.OUT_MKT_TD:
|
|
td.export(fixed)
|
|
|
|
return True
|
|
|
|
def _handle_status(self, message: str) -> None:
|
|
"""
|
|
Set the status label text if it's not already set.
|
|
|
|
:param message: Status message to display.
|
|
"""
|
|
if not self.status['text']:
|
|
self.status['text'] = message
|
|
|
|
def capi_request_data(self, event=None) -> None: # noqa: 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.
|
|
|
|
:param event: Tk generated event details.
|
|
"""
|
|
logger.trace_if('capi.worker', 'Begin')
|
|
should_return: bool
|
|
new_data: dict[str, Any]
|
|
should_return, new_data = killswitch.check_killswitch('capi.auth', {})
|
|
if should_return:
|
|
logger.warning('capi.auth has been disabled via killswitch. Returning.')
|
|
# LANG: CAPI auth query aborted because of killswitch
|
|
self.status['text'] = tr.tl('CAPI auth disabled by killswitch')
|
|
hotkeymgr.play_bad()
|
|
return
|
|
|
|
auto_update = not event
|
|
play_sound = (auto_update or int(event.type) == self.EVENT_VIRTUAL) and not config.get_int('hotkey_mute')
|
|
|
|
if not monitor.cmdr:
|
|
logger.trace_if('capi.worker', 'Aborting Query: Cmdr unknown')
|
|
# LANG: CAPI queries aborted because Cmdr name is unknown
|
|
self.status['text'] = tr.tl('CAPI query aborted: Cmdr name unknown')
|
|
return
|
|
|
|
if not monitor.mode:
|
|
logger.trace_if('capi.worker', 'Aborting Query: Game Mode unknown')
|
|
# LANG: CAPI queries aborted because game mode unknown
|
|
self.status['text'] = tr.tl('CAPI query aborted: Game mode unknown')
|
|
return
|
|
|
|
if monitor.state['GameVersion'] is None:
|
|
logger.trace_if('capi.worker', 'Aborting Query: GameVersion unknown')
|
|
# LANG: CAPI queries aborted because GameVersion unknown
|
|
self.status['text'] = tr.tl('CAPI query aborted: GameVersion unknown')
|
|
return
|
|
|
|
if not monitor.state['SystemName']:
|
|
logger.trace_if('capi.worker', 'Aborting Query: Current star system unknown')
|
|
# LANG: CAPI queries aborted because current star system name unknown
|
|
self.status['text'] = tr.tl('CAPI query aborted: Current system unknown')
|
|
return
|
|
|
|
if monitor.state['Captain']:
|
|
logger.trace_if('capi.worker', 'Aborting Query: In multi-crew')
|
|
# LANG: CAPI queries aborted because player is in multi-crew on other Cmdr's ship
|
|
self.status['text'] = tr.tl('CAPI query aborted: In other-ship multi-crew')
|
|
return
|
|
|
|
if monitor.mode == 'CQC':
|
|
logger.trace_if('capi.worker', 'Aborting Query: In CQC')
|
|
# LANG: CAPI queries aborted because player is in CQC (Arena)
|
|
self.status['text'] = tr.tl('CAPI query aborted: CQC (Arena) detected')
|
|
return
|
|
|
|
if companion.session.state == companion.Session.STATE_AUTH:
|
|
logger.trace_if('capi.worker', 'Auth in progress? Aborting query')
|
|
# Attempt another Auth
|
|
self.login()
|
|
return
|
|
|
|
if not companion.session.retrying:
|
|
if time() < self.capi_query_holdoff_time:
|
|
# Invoked by key while in cooldown
|
|
time_remaining = self.capi_query_holdoff_time - time()
|
|
if play_sound and time_remaining < companion.capi_query_cooldown * 0.75:
|
|
self.status['text'] = ''
|
|
hotkeymgr.play_bad()
|
|
return
|
|
elif play_sound:
|
|
hotkeymgr.play_good()
|
|
|
|
# LANG: Status - Attempting to retrieve data from Frontier CAPI
|
|
self.status['text'] = tr.tl('Fetching data...')
|
|
self.button['state'] = self.theme_button['state'] = tk.DISABLED
|
|
self.w.update_idletasks()
|
|
|
|
query_time = int(time())
|
|
logger.trace_if('capi.worker', 'Requesting full station data')
|
|
config.set('querytime', query_time)
|
|
logger.trace_if('capi.worker', 'Calling companion.session.station')
|
|
|
|
companion.session.station(
|
|
query_time=query_time, tk_response_event=self._CAPI_RESPONSE_TK_EVENT_NAME,
|
|
play_sound=play_sound
|
|
)
|
|
|
|
def capi_request_fleetcarrier_data(self, event=None) -> None:
|
|
"""
|
|
Perform CAPI fleetcarrier data retrieval and associated actions.
|
|
|
|
This is triggered by certain Fleet Carrier journal events
|
|
|
|
:param event: Tk generated event details.
|
|
"""
|
|
logger.trace_if('capi.worker', 'Begin')
|
|
should_return: bool
|
|
new_data: dict[str, Any]
|
|
|
|
should_return, new_data = killswitch.check_killswitch('capi.request.fleetcarrier', {})
|
|
if should_return:
|
|
logger.warning('capi.fleetcarrier has been disabled via killswitch. Returning.')
|
|
# LANG: CAPI fleetcarrier query aborted because of killswitch
|
|
self.status['text'] = tr.tl('CAPI fleetcarrier disabled by killswitch')
|
|
hotkeymgr.play_bad()
|
|
return
|
|
|
|
if not monitor.cmdr:
|
|
logger.trace_if('capi.worker', 'Aborting Query: Cmdr unknown')
|
|
# LANG: CAPI fleetcarrier query aborted because Cmdr name is unknown
|
|
self.status['text'] = tr.tl('CAPI query aborted: Cmdr name unknown')
|
|
return
|
|
|
|
if monitor.state['GameVersion'] is None:
|
|
logger.trace_if('capi.worker', 'Aborting Query: GameVersion unknown')
|
|
# LANG: CAPI fleetcarrier query aborted because GameVersion unknown
|
|
self.status['text'] = tr.tl('CAPI query aborted: GameVersion unknown')
|
|
return
|
|
|
|
if not companion.session.retrying:
|
|
if time() < self.capi_fleetcarrier_query_holdoff_time: # Was invoked while in cooldown
|
|
logger.debug('CAPI fleetcarrier query aborted, too soon since last request')
|
|
return
|
|
|
|
# LANG: Status - Attempting to retrieve data from Frontier CAPI
|
|
self.status['text'] = tr.tl('Fetching data...')
|
|
self.w.update_idletasks()
|
|
|
|
query_time = int(time())
|
|
logger.trace_if('capi.worker', 'Requesting Fleet Carrier data')
|
|
config.set('fleetcarrierquerytime', query_time)
|
|
logger.trace_if('capi.worker', 'Calling companion.session.fleetcarrier')
|
|
companion.session.fleetcarrier(
|
|
query_time=query_time, tk_response_event=self._CAPI_RESPONSE_TK_EVENT_NAME
|
|
)
|
|
|
|
def capi_handle_response(self, event=None): # noqa: C901, CCR001
|
|
"""
|
|
Handle the resulting data from a CAPI query.
|
|
|
|
:param event: generated event details.
|
|
"""
|
|
logger.trace_if('capi.worker', 'Handling response')
|
|
play_bad: bool = False
|
|
err: str | None = None
|
|
|
|
capi_response: companion.EDMCCAPIFailedRequest | companion.EDMCCAPIResponse
|
|
try:
|
|
logger.trace_if('capi.worker', 'Pulling answer off queue')
|
|
capi_response = companion.session.capi_response_queue.get(block=False)
|
|
if isinstance(capi_response, companion.EDMCCAPIFailedRequest):
|
|
logger.trace_if('capi.worker', f'Failed Request: {capi_response.message}')
|
|
if capi_response.exception:
|
|
raise capi_response.exception
|
|
|
|
raise ValueError(capi_response.message)
|
|
|
|
logger.trace_if('capi.worker', 'Answer is not a Failure')
|
|
if not isinstance(capi_response, companion.EDMCCAPIResponse):
|
|
msg = f'Response was neither CAPIFailedRequest nor EDMCAPIResponse: {type(capi_response)}'
|
|
logger.error(msg)
|
|
raise ValueError(msg)
|
|
|
|
if capi_response.capi_data.source_endpoint == companion.session.FRONTIER_CAPI_PATH_FLEETCARRIER:
|
|
# Fleetcarrier CAPI response
|
|
# Validation
|
|
if 'name' not in capi_response.capi_data:
|
|
# LANG: No data was returned for the fleetcarrier from the Frontier CAPI
|
|
err = self.status['text'] = tr.tl('CAPI: No Fleet Carrier data returned')
|
|
|
|
elif not capi_response.capi_data.get('name', {}).get('callsign'):
|
|
# LANG: We didn't have the fleetcarrier callsign when we should have
|
|
err = self.status['text'] = tr.tl("CAPI: Fleet Carrier data incomplete") # Shouldn't happen
|
|
|
|
else:
|
|
if __debug__: # Recording
|
|
companion.session.dump_capi_data(capi_response.capi_data)
|
|
|
|
err = plug.notify_capi_fleetcarrierdata(capi_response.capi_data)
|
|
self.status['text'] = err and err or ''
|
|
if err:
|
|
play_bad = True
|
|
|
|
self.capi_fleetcarrier_query_holdoff_time = capi_response.query_time \
|
|
+ companion.capi_fleetcarrier_query_cooldown
|
|
|
|
# Other CAPI response
|
|
# Validation
|
|
elif 'commander' not in capi_response.capi_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'] = tr.tl('CAPI: No commander data returned')
|
|
|
|
elif not capi_response.capi_data.get('commander', {}).get('name'):
|
|
# LANG: We didn't have the commander name when we should have
|
|
err = self.status['text'] = tr.tl("Who are you?!") # Shouldn't happen
|
|
|
|
elif (not capi_response.capi_data.get('lastSystem', {}).get('name')
|
|
or (capi_response.capi_data['commander'].get('docked')
|
|
and not capi_response.capi_data.get('lastStarport', {}).get('name'))):
|
|
# LANG: We don't know where the commander is, when we should
|
|
err = self.status['text'] = tr.tl("Where are you?!") # Shouldn't happen
|
|
|
|
elif (
|
|
not capi_response.capi_data.get('ship', {}).get('name')
|
|
or not capi_response.capi_data.get('ship', {}).get('modules')
|
|
):
|
|
# LANG: We don't know what ship the commander is in, when we should
|
|
err = self.status['text'] = tr.tl("What are you flying?!") # Shouldn't happen
|
|
|
|
elif monitor.cmdr and capi_response.capi_data['commander']['name'] != monitor.cmdr:
|
|
# Companion API Commander doesn't match Journal
|
|
logger.trace_if('capi.worker', 'Raising CmdrError()')
|
|
raise companion.CmdrError()
|
|
|
|
elif (
|
|
capi_response.auto_update and not monitor.state['OnFoot']
|
|
and not capi_response.capi_data['commander'].get('docked')
|
|
):
|
|
# auto update is only when just docked
|
|
logger.warning(f"{capi_response.auto_update!r} and not {monitor.state['OnFoot']!r} and "
|
|
f"not {capi_response.capi_data['commander'].get('docked')!r}")
|
|
raise companion.ServerLagging()
|
|
|
|
elif capi_response.capi_data['lastSystem']['name'] != monitor.state['SystemName']:
|
|
# CAPI system must match last journal one
|
|
logger.warning(f"{capi_response.capi_data['lastSystem']['name']!r} != "
|
|
f"{monitor.state['SystemName']!r}")
|
|
raise companion.ServerLagging()
|
|
|
|
elif capi_response.capi_data['lastStarport']['name'] != monitor.state['StationName']:
|
|
if monitor.state['OnFoot'] and monitor.state['StationName']:
|
|
logger.warning(f"({capi_response.capi_data['lastStarport']['name']!r} != "
|
|
f"{monitor.state['StationName']!r}) AND "
|
|
f"{monitor.state['OnFoot']!r} and {monitor.state['StationName']!r}")
|
|
raise companion.ServerLagging()
|
|
|
|
if capi_response.capi_data['commander']['docked'] and monitor.state['StationName'] is None:
|
|
# Likely (re-)Embarked on ship docked at an EDO settlement.
|
|
# Both Disembark and Embark have `"Onstation": false` in Journal.
|
|
# So there's nothing to tell us which settlement we're (still,
|
|
# or now, if we came here in Apex and then recalled ship) docked at.
|
|
logger.debug("docked AND monitor.state['StationName'] is None - so EDO settlement?")
|
|
raise companion.NoMonitorStation()
|
|
|
|
self.capi_query_holdoff_time = capi_response.query_time + companion.capi_query_cooldown
|
|
|
|
elif capi_response.capi_data['lastStarport']['id'] != monitor.state['MarketID']:
|
|
logger.warning(f"MarketID mis-match: {capi_response.capi_data['lastStarport']['id']!r} !="
|
|
f" {monitor.state['MarketID']!r}")
|
|
raise companion.ServerLagging()
|
|
|
|
elif not monitor.state['OnFoot'] and capi_response.capi_data['ship']['id'] != monitor.state['ShipID']:
|
|
# CAPI ship must match
|
|
logger.warning(f"not {monitor.state['OnFoot']!r} and "
|
|
f"{capi_response.capi_data['ship']['id']!r} != {monitor.state['ShipID']!r}")
|
|
raise companion.ServerLagging()
|
|
|
|
elif (
|
|
not monitor.state['OnFoot']
|
|
and capi_response.capi_data['ship']['name'].lower() != monitor.state['ShipType']
|
|
):
|
|
# CAPI ship type must match
|
|
logger.warning(f"not {monitor.state['OnFoot']!r} and "
|
|
f"{capi_response.capi_data['ship']['name'].lower()!r} != "
|
|
f"{monitor.state['ShipType']!r}")
|
|
raise companion.ServerLagging()
|
|
|
|
else:
|
|
# TODO: Change to depend on its own CL arg
|
|
if __debug__: # Recording
|
|
companion.session.dump_capi_data(capi_response.capi_data)
|
|
|
|
if not monitor.state['ShipType']: # Started game in SRV or fighter
|
|
self.ship['text'] = ship_name_map.get(
|
|
capi_response.capi_data['ship']['name'].lower(),
|
|
capi_response.capi_data['ship']['name']
|
|
)
|
|
monitor.state['ShipID'] = capi_response.capi_data['ship']['id']
|
|
monitor.state['ShipType'] = capi_response.capi_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 := capi_response.capi_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(
|
|
capi_response.capi_data['loadouts'], loadout['loadoutSlotId']
|
|
)['name']
|
|
|
|
self.suit['text'] = f'{suitname} ({loadout_name})'
|
|
|
|
self.suit_show_if_set()
|
|
# Update Odyssey Suit data
|
|
companion.session.suit_update(capi_response.capi_data)
|
|
|
|
if capi_response.capi_data['commander'].get('credits') is not None:
|
|
monitor.state['Credits'] = capi_response.capi_data['commander']['credits']
|
|
monitor.state['Loan'] = capi_response.capi_data['commander'].get('debt', 0)
|
|
|
|
# stuff we can do when not docked
|
|
err = plug.notify_capidata(capi_response.capi_data, monitor.is_beta)
|
|
self.status['text'] = err and err or ''
|
|
if err:
|
|
play_bad = True
|
|
|
|
should_return: bool
|
|
new_data: dict[str, Any]
|
|
|
|
should_return, new_data = killswitch.check_killswitch('capi.request./market', {})
|
|
if should_return:
|
|
logger.warning("capi.request./market has been disabled by killswitch. Returning.")
|
|
|
|
else:
|
|
# Export market data
|
|
if not self.export_market_data(capi_response.capi_data):
|
|
err = 'Error: Exporting Market data'
|
|
play_bad = True
|
|
|
|
self.capi_query_holdoff_time = capi_response.query_time + companion.capi_query_cooldown
|
|
|
|
except queue.Empty:
|
|
logger.error('There was no response in the queue!')
|
|
# TODO: Set status text
|
|
return
|
|
|
|
except companion.ServerConnectionError as comp_err:
|
|
# LANG: Frontier CAPI server error when fetching data
|
|
self.status['text'] = tr.tl('Frontier CAPI server error')
|
|
logger.warning(f'Exception while contacting server: {comp_err}')
|
|
err = self.status['text'] = str(comp_err)
|
|
play_bad = True
|
|
|
|
except companion.CredentialsRequireRefresh:
|
|
# We need to 'close' the auth else it'll see STATE_OK and think login() isn't needed
|
|
companion.session.reinit_session()
|
|
# LANG: Frontier CAPI Access Token expired, trying to get a new one
|
|
self.status['text'] = tr.tl('CAPI: Refreshing access token...')
|
|
if companion.session.login():
|
|
logger.debug('Initial query failed, but login() just worked, trying again...')
|
|
companion.session.retrying = True
|
|
self.w.after(int(SERVER_RETRY * 1000), lambda: self.capi_request_data(event))
|
|
return # early exit to avoid starting cooldown count
|
|
|
|
except companion.CredentialsError:
|
|
companion.session.retrying = False
|
|
companion.session.invalidate()
|
|
companion.session.login()
|
|
return # We need to give Auth time to complete, so can't set a timed retry
|
|
|
|
# Companion API problem
|
|
except companion.ServerLagging as e:
|
|
err = str(e)
|
|
if companion.session.retrying:
|
|
self.status['text'] = err
|
|
play_bad = True
|
|
|
|
else:
|
|
# Retry once if Companion server is unresponsive
|
|
companion.session.retrying = True
|
|
self.w.after(int(SERVER_RETRY * 1000), lambda: self.capi_request_data(event))
|
|
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(tr.tl('Last updated at %H:%M:%S'), localtime(capi_response.query_time))
|
|
|
|
if capi_response.play_sound and play_bad:
|
|
hotkeymgr.play_bad()
|
|
|
|
logger.trace_if('capi.worker', 'Updating suit and cooldown...')
|
|
self.update_suit_text()
|
|
self.suit_show_if_set()
|
|
self.cooldown()
|
|
logger.trace_if('capi.worker', '...done')
|
|
|
|
def journal_event(self, event: str): # 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': tr.tl('Fighter'), # LANG: Multicrew role
|
|
'FireCon': tr.tl('Gunner'), # LANG: Multicrew role
|
|
'FlightCon': tr.tl('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_if('journal.queue', 'No entry from monitor.get_entry()')
|
|
return
|
|
|
|
# Update main window
|
|
self.cooldown()
|
|
if monitor.cmdr and monitor.state['Captain']:
|
|
if not config.get_bool('hide_multicrew_captain', default=False):
|
|
self.cmdr['text'] = f'{monitor.cmdr} / {monitor.state["Captain"]}'
|
|
|
|
else:
|
|
self.cmdr['text'] = f'{monitor.cmdr}'
|
|
|
|
self.ship_label['text'] = tr.tl('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 and not config.get_bool("hide_private_group", default=False):
|
|
self.cmdr['text'] = f'{monitor.cmdr} / {monitor.group}'
|
|
|
|
else:
|
|
self.cmdr['text'] = monitor.cmdr
|
|
|
|
self.ship_label['text'] = tr.tl('Ship') + ':' # LANG: 'Ship' label in main UI
|
|
|
|
# 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: Literal['normal', 'disabled'] = tk.NORMAL
|
|
|
|
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'] = tr.tl('Ship') + ':' # LANG: 'Ship' label in main UI
|
|
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.state['SystemName'] 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 monitor.cmdr and monitor.mode == 'CQC' and entry['event']:
|
|
err = plug.notify_journal_entry_cqc(monitor.cmdr, monitor.is_beta, entry, monitor.state)
|
|
if err:
|
|
self.status['text'] = err
|
|
if not config.get_int('hotkey_mute'):
|
|
hotkeymgr.play_bad()
|
|
|
|
return # in CQC
|
|
|
|
if not entry['event'] or not monitor.mode:
|
|
logger.trace_if('journal.queue', 'Startup, returning')
|
|
return # Startup
|
|
|
|
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'):
|
|
if self.updater is not None:
|
|
self.updater.set_automatic_updates_check(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()
|
|
|
|
if monitor.cmdr:
|
|
err = plug.notify_journal_entry(
|
|
monitor.cmdr,
|
|
monitor.is_beta,
|
|
monitor.state['SystemName'],
|
|
monitor.state['StationName'],
|
|
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.state['StationName']:
|
|
# 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
|
|
|
|
should_return: bool
|
|
new_data: dict[str, Any]
|
|
|
|
if auto_update:
|
|
should_return, new_data = killswitch.check_killswitch('capi.auth', {})
|
|
if not should_return:
|
|
self.w.after(int(SERVER_RETRY * 1000), self.capi_request_data)
|
|
|
|
if entry['event'] in ('CarrierBuy', 'CarrierStats') and config.get_bool('capi_fleetcarrier'):
|
|
should_return, new_data = killswitch.check_killswitch('capi.request.fleetcarrier', {})
|
|
if not should_return:
|
|
self.w.after(int(SERVER_RETRY * 1000), self.capi_request_fleetcarrier_data)
|
|
|
|
if entry['event'] == 'ShutDown':
|
|
# Enable WinSparkle automatic update checks
|
|
# NB: Do this blindly, in case option got changed whilst in-game
|
|
if self.updater is not None:
|
|
self.updater.set_automatic_updates_check(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'] = tr.tl('Authentication successful')
|
|
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
|
|
if monitor.cmdr:
|
|
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.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 | None:
|
|
"""Dispatch 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 = config.app_dir_path / "last_shipyard.html"
|
|
|
|
with open(file_name, 'w') as f:
|
|
f.write(SHIPYARD_HTML_TEMPLATE.format(
|
|
link=html.escape(str(target)),
|
|
provider_name=html.escape(str(provider)),
|
|
ship_name=html.escape(str(shipname))
|
|
))
|
|
|
|
return f'file://localhost/{file_name}'
|
|
|
|
def system_url(self, system: str) -> str | None:
|
|
"""Dispatch a system URL to the configured handler."""
|
|
return plug.invoke(
|
|
config.get_str('system_provider', default='EDSM'), 'EDSM', 'system_url', monitor.state['SystemName']
|
|
)
|
|
|
|
def station_url(self, station: str) -> str | None:
|
|
"""Dispatch a station URL to the configured handler."""
|
|
return plug.invoke(
|
|
config.get_str('station_provider', default='EDSM'), 'EDSM', 'station_url',
|
|
monitor.state['SystemName'], monitor.state['StationName']
|
|
)
|
|
|
|
def cooldown(self) -> None:
|
|
"""Display and update the cooldown timer for 'Update' button."""
|
|
if time() < self.capi_query_holdoff_time:
|
|
# Update button in main window
|
|
cooldown_time = int(self.capi_query_holdoff_time - time())
|
|
# LANG: Cooldown on 'Update' button
|
|
self.button['text'] = self.theme_button['text'] = tr.tl('cooldown {SS}s').format(SS=cooldown_time)
|
|
self.w.after(1000, self.cooldown)
|
|
else:
|
|
self.button['text'] = self.theme_button['text'] = tr.tl('Update') # LANG: Update button in main window
|
|
self.button['state'] = self.theme_button['state'] = (
|
|
monitor.cmdr and
|
|
monitor.mode and
|
|
monitor.mode != 'CQC' and
|
|
not monitor.state['Captain'] and
|
|
monitor.state['SystemName'] and
|
|
tk.NORMAL or tk.DISABLED
|
|
)
|
|
|
|
if sys.platform == 'win32':
|
|
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.state['SystemName']:
|
|
clipboard_text = f"{monitor.state['SystemName']},{monitor.state['StationName']}" if monitor.state[
|
|
'StationName'] else monitor.state['SystemName']
|
|
self.w.clipboard_clear()
|
|
self.w.clipboard_append(clipboard_text)
|
|
|
|
def help_general(self, event=None) -> None:
|
|
"""Open Wiki Help page in browser."""
|
|
webbrowser.open('https://github.com/EDCD/EDMarketConnector/wiki')
|
|
|
|
def help_troubleshooting(self, event=None) -> None:
|
|
"""Open Wiki Privacy page in browser."""
|
|
webbrowser.open("https://github.com/EDCD/EDMarketConnector/wiki/Troubleshooting")
|
|
|
|
def help_report_a_bug(self, event=None) -> None:
|
|
"""Open Wiki Privacy page in browser."""
|
|
webbrowser.open("https://github.com/EDCD/EDMarketConnector/issues/new?assignees=&labels=bug%2C+unconfirmed"
|
|
"&template=bug_report.md&title=")
|
|
|
|
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: bool = False
|
|
|
|
def __init__(self, parent: tk.Tk) -> None:
|
|
"""
|
|
Initialize the HelpAbout popup.
|
|
|
|
:param parent: The parent Tk window.
|
|
"""
|
|
if self.__class__.showing:
|
|
return
|
|
|
|
self.__class__.showing = True
|
|
|
|
tk.Toplevel.__init__(self, parent)
|
|
|
|
self.parent = parent
|
|
# LANG: Help > About App
|
|
self.title(tr.tl('About {APP}').format(APP=applongname))
|
|
|
|
if parent.winfo_viewable():
|
|
self.transient(parent)
|
|
|
|
# position over parent
|
|
# http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7
|
|
self.geometry(f'+{parent.winfo_rootx():d}+{parent.winfo_rooty():d}')
|
|
|
|
# remove decoration
|
|
if sys.platform == 'win32':
|
|
self.attributes('-toolwindow', tk.TRUE)
|
|
|
|
self.resizable(tk.FALSE, tk.FALSE)
|
|
|
|
frame = tk.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 <link to changelog>
|
|
tk.Label(frame).grid(row=row, column=0) # spacer
|
|
row += 1
|
|
self.appversion_label = tk.Text(frame, height=1, width=len(str(appversion())), wrap=tk.NONE, bd=0)
|
|
self.appversion_label.insert("1.0", str(appversion()))
|
|
self.appversion_label.tag_configure("center", justify="center")
|
|
self.appversion_label.tag_add("center", "1.0", "end")
|
|
self.appversion_label.config(state=tk.DISABLED, bg=frame.cget("background"), font="TkDefaultFont")
|
|
self.appversion_label.grid(row=row, column=0, sticky=tk.E)
|
|
# LANG: Help > Release Notes
|
|
self.appversion = HyperlinkLabel(frame, compound=tk.RIGHT, text=tr.tl('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
|
|
############################################################
|
|
|
|
############################################################
|
|
# <whether up to date>
|
|
############################################################
|
|
|
|
############################################################
|
|
# <copyright>
|
|
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
|
|
# LANG: Generic 'OK' button label
|
|
button = ttk.Button(frame, text=tr.tl('OK'), command=self.apply)
|
|
button.grid(row=row, column=2, sticky=tk.E)
|
|
button.bind("<Return>", 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:
|
|
"""
|
|
Save any CAPI data already acquired to a file.
|
|
|
|
This specifically does *not* cause new queries to be performed, as the
|
|
purpose is to aid in diagnosing any issues that occurred during 'normal'
|
|
queries.
|
|
"""
|
|
default_extension: str = ''
|
|
|
|
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"{monitor.state['SystemName']}.{monitor.state['StationName']}.{timestamp}"
|
|
)
|
|
if not f:
|
|
return
|
|
|
|
with open(f, 'wb') as h:
|
|
h.write(str(companion.session.capi_raw_data).encode(encoding='utf-8'))
|
|
|
|
if sys.platform == 'win32':
|
|
def exit_tray(self, systray: 'SysTrayIcon') -> None:
|
|
"""Tray icon is shutting down."""
|
|
exit_thread = threading.Thread(
|
|
target=self.onexit,
|
|
daemon=True,
|
|
)
|
|
exit_thread.start()
|
|
|
|
def onexit(self, event=None, restart: bool = False) -> None:
|
|
"""Application shutdown procedure."""
|
|
if sys.platform == 'win32':
|
|
shutdown_thread = threading.Thread(
|
|
target=self.systray.shutdown,
|
|
daemon=True,
|
|
)
|
|
shutdown_thread.start()
|
|
|
|
config.set_shutdown() # Signal we're in shutdown now.
|
|
|
|
# http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7
|
|
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'] = tr.tl('Shutting down...')
|
|
self.w.update_idletasks()
|
|
logger.info('Starting shutdown procedures...')
|
|
|
|
# First so it doesn't interrupt us
|
|
logger.info('Closing update checker...')
|
|
if self.updater is not None:
|
|
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 CAPI query thread
|
|
logger.info('Closing CAPI query thread...')
|
|
companion.session.capi_query_close_worker()
|
|
|
|
# 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...')
|
|
protocol.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.')
|
|
if restart:
|
|
os.execv(sys.executable, ['python'] + sys.argv)
|
|
|
|
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 default_iconify(self, event=None) -> None:
|
|
"""Handle the Windows default theme 'minimise' button."""
|
|
# If we're meant to "minimize to system tray" then hide the window so no taskbar icon is seen
|
|
if sys.platform == 'win32' and config.get_bool('minimize_system_tray'):
|
|
# This gets called for more than the root widget, so only react to that
|
|
if str(event.widget) == '.':
|
|
self.w.withdraw()
|
|
|
|
def oniconify(self, event=None) -> None:
|
|
"""Handle the minimize button on non-Default theme main window."""
|
|
self.w.overrideredirect(False) # 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."""
|
|
if config.get_int('theme') == theme.THEME_TRANSPARENT:
|
|
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."""
|
|
if config.get_int('theme') == theme.THEME_TRANSPARENT 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(filename: str | None):
|
|
"""Download and setup the main killswitch list."""
|
|
logger.debug('fetching killswitches...')
|
|
if filename is not None:
|
|
filename = "file:" + filename
|
|
|
|
killswitch.setup_main_list(filename)
|
|
|
|
|
|
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, kill in version.kills.items():
|
|
tk.Label(frame, text=id).grid(column=0, row=idx, sticky=tk.W, padx=(10, 0))
|
|
tk.Label(frame, text=kill.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)
|
|
|
|
|
|
def validate_providers():
|
|
"""Check if Config has an invalid provider set, and reset to default if we do."""
|
|
reset_providers = {}
|
|
station_provider: str = config.get_str("station_provider")
|
|
if station_provider not in plug.provides('station_url'):
|
|
logger.error("Station Provider Not Valid. Setting to Default.")
|
|
config.set('station_provider', 'EDSM')
|
|
reset_providers["Station"] = (station_provider, "EDSM")
|
|
|
|
shipyard_provider: str = config.get_str("shipyard_provider")
|
|
if shipyard_provider not in plug.provides('shipyard_url'):
|
|
logger.error("Shipyard Provider Not Valid. Setting to Default.")
|
|
config.set('shipyard_provider', 'EDSY')
|
|
reset_providers["Shipyard"] = (shipyard_provider, "EDSY")
|
|
|
|
system_provider: str = config.get_str("system_provider")
|
|
if system_provider not in plug.provides('system_url'):
|
|
logger.error("System Provider Not Valid. Setting to Default.")
|
|
config.set('system_provider', 'EDSM')
|
|
reset_providers["System"] = (system_provider, "EDSM")
|
|
|
|
if not reset_providers:
|
|
return
|
|
|
|
# LANG: Popup-text about Reset Providers
|
|
popup_text = tr.tl(r'One or more of your URL Providers were invalid, and have been reset:\r\n\r\n')
|
|
for provider in reset_providers:
|
|
# LANG: Text About What Provider Was Reset
|
|
popup_text += tr.tl(r'{PROVIDER} was set to {OLDPROV}, and has been reset to {NEWPROV}\r\n')
|
|
popup_text = popup_text.format(
|
|
PROVIDER=provider,
|
|
OLDPROV=reset_providers[provider][0],
|
|
NEWPROV=reset_providers[provider][1]
|
|
)
|
|
# 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(
|
|
# LANG: Popup window title for Reset Providers
|
|
tr.tl('EDMC: Default Providers Reset'),
|
|
popup_text,
|
|
parent=root
|
|
)
|
|
|
|
|
|
# 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', theme.THEME_DEFAULT)
|
|
config.set('ui_transparency', 100) # 100 is completely opaque
|
|
config.delete('font', suppress=True)
|
|
config.delete('font_size', suppress=True)
|
|
|
|
config.set('ui_scale', 100) # 100% is the default here
|
|
config.delete('geometry', suppress=True) # unset is recreated by other code
|
|
|
|
logger.info('reset theme, transparency, font, font size, ui scale, and ui geometry 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()
|
|
|
|
# <https://en.wikipedia.org/wiki/Windows_10_version_history#Version_1903_(May_2019_Update)>
|
|
# 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: <https://en.wikipedia.org/wiki/UTF-8#Naming>
|
|
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)')
|
|
|
|
# HACK: n/a | 2021-11-24: --force-localserver-auth does not work if companion is imported early -cont.
|
|
# HACK: n/a | 2021-11-24: as we modify config before this is used.
|
|
import companion
|
|
from companion import CAPIData, index_possibly_sparse_list
|
|
|
|
# Do this after locale silliness, just in case
|
|
if args.forget_frontier_auth:
|
|
logger.info("Dropping all fdev tokens as --forget-frontier-auth was passed")
|
|
companion.Auth.invalidate(None)
|
|
|
|
# Create protocol handler
|
|
protocol.protocolhandler = protocol.get_handler_impl()()
|
|
|
|
# TODO: unittests in place of these
|
|
# logger.debug('Test from __main__')
|
|
# test_logging()
|
|
|
|
class A:
|
|
"""Simple top-level class."""
|
|
|
|
class B:
|
|
"""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()}')
|
|
|
|
tr.install(config.get_str('language')) # Can generate errors so wait til log set up
|
|
|
|
setup_killswitches(args.killswitches_file)
|
|
|
|
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_if('tk', f'Default tk scaling = {theme.default_ui_scale}')
|
|
theme.startup_ui_scale = ui_scale
|
|
if theme.default_ui_scale is not None:
|
|
root.tk.call('tk', 'scaling', theme.default_ui_scale * float(ui_scale) / 100.0)
|
|
|
|
try:
|
|
app = AppWindow(root)
|
|
except Exception as err:
|
|
logger.exception(f"EDMC Critical Error: {err}")
|
|
title = tr.tl("Error") # LANG: Generic error prefix
|
|
message = tr.tl( # LANG: EDMC Critical Error Notification
|
|
"EDMC encountered a critical error, and cannot recover. EDMC is shutting down for its own protection!"
|
|
)
|
|
err = f"{err.__class__.__name__}: {err}" # type: ignore # hijacking the existing exception detection
|
|
detail = tr.tl( # LANG: EDMC Critical Error Details
|
|
r"Here's what EDMC Detected:\r\n\r\n{ERR}\r\n\r\nDo you want to file a Bug Report on GitHub?"
|
|
).format(ERR=err)
|
|
detail = detail.replace('\\n', '\n')
|
|
detail = detail.replace('\\r', '\r')
|
|
msg = tk.messagebox.askyesno(
|
|
title=title, message=message, detail=detail, icon=tkinter.messagebox.ERROR, type=tkinter.messagebox.YESNO,
|
|
parent=root
|
|
)
|
|
if msg:
|
|
webbrowser.open(
|
|
"https://github.com/EDCD/EDMarketConnector/issues/new?"
|
|
"assignees=&labels=bug%2C+unconfirmed&projects=&template=bug_report.md&title="
|
|
)
|
|
os.kill(os.getpid(), signal.SIGTERM)
|
|
|
|
def messagebox_broken_plugins():
|
|
"""Display message about 'broken' plugins that failed to load."""
|
|
if plug.PLUGINS_broken:
|
|
# LANG: Popup-text about 'broken' plugins that failed to load
|
|
popup_text = tr.tl(
|
|
"One or more of your enabled plugins failed to load. Please see the list on the '{PLUGINS}' "
|
|
"tab of '{FILE}' > '{SETTINGS}'. This could be caused by a wrong folder structure. The load.py "
|
|
r"file should be located under plugins/PLUGIN_NAME/load.py.\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.
|
|
popup_text = popup_text.format(
|
|
PLUGINS=tr.tl('Plugins'), # LANG: Settings > Plugins tab
|
|
FILE=tr.tl('File'), # LANG: 'File' menu
|
|
SETTINGS=tr.tl('Settings'), # LANG: File > 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(
|
|
# LANG: Popup window title for list of 'broken' plugins that failed to load
|
|
tr.tl('EDMC: Broken Plugins'),
|
|
popup_text,
|
|
parent=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 plug.PLUGINS_not_py3:
|
|
# LANG: Popup-text about 'active' plugins without Python 3.x support
|
|
popup_text = tr.tl(
|
|
"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.
|
|
popup_text = popup_text.format(
|
|
PLUGINS=tr.tl('Plugins'), # LANG: Settings > Plugins tab
|
|
FILE=tr.tl('File'), # LANG: 'File' menu
|
|
SETTINGS=tr.tl('Settings'), # LANG: File > 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(
|
|
# LANG: Popup window title for list of 'enabled' plugins that don't work with Python 3.x
|
|
tr.tl('EDMC: Plugins Without Python 3.x Support'),
|
|
popup_text,
|
|
parent=root
|
|
)
|
|
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)
|
|
# Display message box about plugins that failed to load
|
|
root.after(0, messagebox_broken_plugins)
|
|
# Display message box about plugins without Python 3.x support
|
|
root.after(1, messagebox_not_py3)
|
|
# Show warning popup for killswitches matching current version
|
|
root.after(2, show_killswitch_poppup, root)
|
|
# Start the main event loop
|
|
try:
|
|
check_for_fdev_updates()
|
|
root.mainloop()
|
|
except KeyboardInterrupt:
|
|
logger.info("Ctrl+C Detected, Attempting Clean Shutdown")
|
|
app.onexit()
|
|
logger.info('Exiting')
|