mirror of
https://github.com/EDCD/EDMarketConnector.git
synced 2025-04-13 07:47:14 +03:00
This is the code path for when something ignores the ddeexec registry key and instead invokes EDMarketConnector.exe with the edmc://... argument directly. Our response is to try ShellExecute() to invoke the DDE path instead. If the DDE path isn't set up (registry key `Computer\HKEY_CLASSES_ROOT\edmc\shell\open\ddeexec` not present and correct), then this will cause a loop of repeatedly invoking EDMarketConnector.exe. That key is inserted by the installer. The only reasons for it to not be there would be something blocking the installer from creating it or something removing it after install.
1385 lines
64 KiB
Python
Executable File
1385 lines
64 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
|
|
import argparse
|
|
import html
|
|
import json
|
|
import locale
|
|
import re
|
|
import sys
|
|
import webbrowser
|
|
from builtins import object, str
|
|
from os import chdir, environ
|
|
from os.path import dirname, isdir, join
|
|
from sys import platform
|
|
from time import localtime, strftime, time
|
|
from typing import TYPE_CHECKING
|
|
|
|
from constants import applongname, appname, protocolhandler_redirect
|
|
|
|
# config will now cause an appname logger to be set up, so we need the
|
|
# console redirect before this
|
|
if __name__ == '__main__':
|
|
# Keep this as the very first code run to be as sure as possible of no
|
|
# output until after this redirect is done, if needed.
|
|
if getattr(sys, 'frozen', False):
|
|
# By default py2exe tries to write log to dirname(sys.executable) which fails when installed
|
|
import tempfile
|
|
|
|
# unbuffered not allowed for text in python3, so use `1 for line buffering
|
|
sys.stdout = sys.stderr = open(join(tempfile.gettempdir(), f'{appname}.log'), mode='a', buffering=1)
|
|
# TODO: Test: Make *sure* this redirect is working, else py2exe is going to cause an exit popup
|
|
|
|
# After the redirect in case config does logging setup
|
|
from config import appversion, appversion_nobuild, config, copyright
|
|
from EDMCLogging import edmclogger, logger, logging
|
|
from journal_lock import JournalLock, JournalLockResult
|
|
|
|
if __name__ == '__main__': # noqa: C901
|
|
# Command-line arguments
|
|
parser = argparse.ArgumentParser(
|
|
prog=appname,
|
|
description="Utilises Elite Dangerous Journal files and the Frontier "
|
|
"Companion API (CAPI) service to gather data about a "
|
|
"player's state and actions to upload to third-party sites "
|
|
"such as EDSM, Inara.cz and EDDB."
|
|
)
|
|
|
|
parser.add_argument('--trace',
|
|
help='Set the Debug logging loglevel to TRACE',
|
|
action='store_true',
|
|
)
|
|
|
|
parser.add_argument('--suppress-dupe-process-popup',
|
|
help='Suppress the popup from when the application detects another instance already running',
|
|
action='store_true'
|
|
)
|
|
|
|
parser.add_argument('--force-localserver-for-auth',
|
|
help='Force EDMC to use a localhost webserver for Frontier Auth callback',
|
|
action='store_true'
|
|
)
|
|
|
|
parser.add_argument('edmc',
|
|
help='Callback from Frontier Auth',
|
|
nargs='*'
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
if args.trace:
|
|
logger.setLevel(logging.TRACE)
|
|
edmclogger.set_channels_loglevel(logging.TRACE)
|
|
else:
|
|
edmclogger.set_channels_loglevel(logging.DEBUG)
|
|
|
|
if args.force_localserver_for_auth:
|
|
config.set_auth_force_localserver()
|
|
|
|
def handle_edmc_callback_or_foregrounding(): # noqa: CCR001
|
|
"""Handle any edmc:// auth callback, else foreground existing window."""
|
|
logger.trace('Begin...')
|
|
|
|
if platform == 'win32':
|
|
|
|
# If *this* instance hasn't locked, then another already has and we
|
|
# now need to do the edmc:// checks for auth callback
|
|
if locked != JournalLockResult.LOCKED:
|
|
import ctypes
|
|
from ctypes.wintypes import BOOL, HWND, INT, LPARAM, LPCWSTR, LPWSTR
|
|
|
|
EnumWindows = ctypes.windll.user32.EnumWindows # noqa: N806
|
|
GetClassName = ctypes.windll.user32.GetClassNameW # noqa: N806
|
|
GetClassName.argtypes = [HWND, LPWSTR, ctypes.c_int]
|
|
GetWindowText = ctypes.windll.user32.GetWindowTextW # noqa: N806
|
|
GetWindowText.argtypes = [HWND, LPWSTR, ctypes.c_int]
|
|
GetWindowTextLength = ctypes.windll.user32.GetWindowTextLengthW # noqa: N806
|
|
GetProcessHandleFromHwnd = ctypes.windll.oleacc.GetProcessHandleFromHwnd # noqa: N806
|
|
|
|
SW_RESTORE = 9 # noqa: N806
|
|
SetForegroundWindow = ctypes.windll.user32.SetForegroundWindow # noqa: N806
|
|
ShowWindow = ctypes.windll.user32.ShowWindow # noqa: N806
|
|
ShowWindowAsync = ctypes.windll.user32.ShowWindowAsync # noqa: N806
|
|
|
|
COINIT_MULTITHREADED = 0 # noqa: N806,F841
|
|
COINIT_APARTMENTTHREADED = 0x2 # noqa: N806
|
|
COINIT_DISABLE_OLE1DDE = 0x4 # noqa: N806
|
|
CoInitializeEx = ctypes.windll.ole32.CoInitializeEx # noqa: N806
|
|
|
|
ShellExecute = ctypes.windll.shell32.ShellExecuteW # noqa: N806
|
|
ShellExecute.argtypes = [HWND, LPCWSTR, LPCWSTR, LPCWSTR, LPCWSTR, INT]
|
|
|
|
def window_title(h):
|
|
if h:
|
|
text_length = GetWindowTextLength(h) + 1
|
|
buf = ctypes.create_unicode_buffer(text_length)
|
|
if GetWindowText(h, buf, text_length):
|
|
return buf.value
|
|
|
|
return None
|
|
|
|
@ctypes.WINFUNCTYPE(BOOL, HWND, LPARAM)
|
|
def enumwindowsproc(window_handle, l_param):
|
|
"""
|
|
Determine if any window for the Application exists.
|
|
|
|
Called for each found window by EnumWindows().
|
|
|
|
When a match is found we check if we're being invoked as the
|
|
edmc://auth handler. If so we send the message to the existing
|
|
process/window. If not we'll raise that existing window to the
|
|
foreground.
|
|
:param window_handle: Window to check.
|
|
:param l_param: The second parameter to the EnumWindows() call.
|
|
:return: False if we found a match, else True to continue iteration
|
|
"""
|
|
# class name limited to 256 - https://msdn.microsoft.com/en-us/library/windows/desktop/ms633576
|
|
cls = ctypes.create_unicode_buffer(257)
|
|
# This conditional is exploded to make debugging slightly easier
|
|
if GetClassName(window_handle, cls, 257):
|
|
if cls.value == 'TkTopLevel':
|
|
if window_title(window_handle) == applongname:
|
|
if GetProcessHandleFromHwnd(window_handle):
|
|
# If GetProcessHandleFromHwnd succeeds then the app is already running as this user
|
|
if len(sys.argv) > 1 and sys.argv[1].startswith(protocolhandler_redirect):
|
|
logger.debug('Invoked with edmc:// protocol handler arg')
|
|
CoInitializeEx(0, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE)
|
|
# Wait for it to be responsive to avoid ShellExecute recursing
|
|
ShowWindow(window_handle, SW_RESTORE)
|
|
ShellExecute(0, None, sys.argv[1], None, None, SW_RESTORE)
|
|
|
|
else:
|
|
ShowWindowAsync(window_handle, SW_RESTORE)
|
|
SetForegroundWindow(window_handle)
|
|
|
|
return False # Indicate window found, so stop iterating
|
|
|
|
# Indicate that EnumWindows() needs to continue iterating
|
|
return True # Do not remove, else this function as a callback breaks
|
|
|
|
# This performs the edmc://auth check and forward
|
|
# EnumWindows() will iterate through all open windows, calling
|
|
# enumwindwsproc() on each. When an invocation returns False it
|
|
# stops iterating.
|
|
# Ref: <https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-enumwindows>
|
|
EnumWindows(enumwindowsproc, 0)
|
|
|
|
return
|
|
|
|
def already_running_popup():
|
|
"""Create the "already running" popup."""
|
|
import tkinter as tk
|
|
from tkinter import ttk
|
|
|
|
# Check for CL arg that suppresses this popup.
|
|
if args.suppress_dupe_process_popup:
|
|
sys.exit(0)
|
|
|
|
root = tk.Tk(className=appname.lower())
|
|
|
|
frame = tk.Frame(root)
|
|
frame.grid(row=1, column=0, sticky=tk.NSEW)
|
|
|
|
label = tk.Label(frame)
|
|
label['text'] = 'An EDMarketConnector.exe process was already running, exiting.'
|
|
label.grid(row=1, column=0, sticky=tk.NSEW)
|
|
|
|
button = ttk.Button(frame, text='OK', command=lambda: sys.exit(0))
|
|
button.grid(row=2, column=0, sticky=tk.S)
|
|
|
|
root.mainloop()
|
|
|
|
journal_lock = JournalLock()
|
|
locked = journal_lock.obtain_lock()
|
|
|
|
handle_edmc_callback_or_foregrounding()
|
|
|
|
if locked == JournalLockResult.ALREADY_LOCKED:
|
|
# There's a copy already running.
|
|
|
|
logger.info("An EDMarketConnector.exe process was already running, exiting.")
|
|
|
|
# To be sure the user knows, we need a popup
|
|
if not args.edmc:
|
|
already_running_popup()
|
|
# If the user closes the popup with the 'X', not the 'OK' button we'll
|
|
# reach here.
|
|
sys.exit(0)
|
|
|
|
if getattr(sys, 'frozen', False):
|
|
# Now that we're sure we're the only instance running we can truncate the logfile
|
|
logger.trace('Truncating plain logfile')
|
|
sys.stdout.seek(0)
|
|
sys.stdout.truncate()
|
|
|
|
|
|
# See EDMCLogging.py docs.
|
|
# isort: off
|
|
if TYPE_CHECKING:
|
|
from logging import trace, TRACE # type: ignore # noqa: F401
|
|
# isort: on
|
|
|
|
def _(x: str) -> str:
|
|
"""Fake the l10n translation functions for typing."""
|
|
return x
|
|
|
|
if getattr(sys, 'frozen', False):
|
|
# Under py2exe sys.path[0] is the executable name
|
|
if platform == 'win32':
|
|
chdir(dirname(sys.path[0]))
|
|
# Allow executable to be invoked from any cwd
|
|
environ['TCL_LIBRARY'] = join(dirname(sys.path[0]), 'lib', 'tcl')
|
|
environ['TK_LIBRARY'] = join(dirname(sys.path[0]), 'lib', 'tk')
|
|
|
|
import tkinter as tk
|
|
import tkinter.filedialog
|
|
import tkinter.font
|
|
import tkinter.messagebox
|
|
from tkinter import ttk
|
|
|
|
import commodity
|
|
import companion
|
|
import plug
|
|
import prefs
|
|
import stats
|
|
import td
|
|
from commodity import COMMODITY_CSV
|
|
from dashboard import dashboard
|
|
from hotkey import hotkeymgr
|
|
from l10n import Translations
|
|
from monitor import monitor
|
|
from protocol import protocolhandler
|
|
from theme import theme
|
|
from ttkHyperlinkLabel import HyperlinkLabel
|
|
|
|
SERVER_RETRY = 5 # retry pause for Companion servers [s]
|
|
|
|
SHIPYARD_HTML_TEMPLATE = """
|
|
<!DOCTYPE HTML>
|
|
<html>
|
|
<head>
|
|
<meta http-equiv="refresh" content="0; url={link}">
|
|
<title>Redirecting you to your {ship_name} at {provider_name}...</title>
|
|
</head>
|
|
<body>
|
|
<a href="{link}">
|
|
You should be redirected to your {ship_name} at {provider_name} shortly...
|
|
</a>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
class AppWindow(object):
|
|
# Tkinter Event types
|
|
EVENT_KEYPRESS = 2
|
|
EVENT_BUTTON = 4
|
|
EVENT_VIRTUAL = 35
|
|
|
|
def __init__(self, master): # noqa: C901
|
|
|
|
self.holdofftime = config.getint('querytime') + companion.holdoff
|
|
|
|
self.w = master
|
|
self.w.title(applongname)
|
|
self.w.rowconfigure(0, weight=1)
|
|
self.w.columnconfigure(0, weight=1)
|
|
|
|
self.prefsdialog = None
|
|
|
|
plug.load_plugins(master)
|
|
|
|
if platform != 'darwin':
|
|
if platform == 'win32':
|
|
self.w.wm_iconbitmap(default='EDMarketConnector.ico')
|
|
else:
|
|
self.w.tk.call('wm', 'iconphoto', self.w, '-default',
|
|
tk.PhotoImage(file=join(config.respath, 'EDMarketConnector.png'))) # noqa: E501
|
|
self.theme_icon = tk.PhotoImage(
|
|
data='R0lGODlhFAAQAMZQAAoKCQoKCgsKCQwKCQsLCgwLCg4LCQ4LCg0MCg8MCRAMCRANChINCREOChIOChQPChgQChgRCxwTCyYVCSoXCS0YCTkdCTseCT0fCTsjDU0jB0EnDU8lB1ElB1MnCFIoCFMoCEkrDlkqCFwrCGEuCWIuCGQvCFs0D1w1D2wyCG0yCF82D182EHE0CHM0CHQ1CGQ5EHU2CHc3CHs4CH45CIA6CIE7CJdECIdLEolMEohQE5BQE41SFJBTE5lUE5pVE5RXFKNaFKVbFLVjFbZkFrxnFr9oFsNqFsVrF8RsFshtF89xF9NzGNh1GNl2GP+KG////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////yH5BAEKAH8ALAAAAAAUABAAAAeegAGCgiGDhoeIRDiIjIZGKzmNiAQBQxkRTU6am0tPCJSGShuSAUcLoIIbRYMFra4FAUgQAQCGJz6CDQ67vAFJJBi0hjBBD0w9PMnJOkAiJhaIKEI7HRoc19ceNAolwbWDLD8uAQnl5ga1I9CHEjEBAvDxAoMtFIYCBy+kFDKHAgM3ZtgYSLAGgwkp3pEyBOJCC2ELB31QATGioAoVAwEAOw==') # noqa: E501
|
|
self.theme_minimize = tk.BitmapImage(
|
|
data='#define im_width 16\n#define im_height 16\nstatic unsigned char im_bits[] = {\n 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x3f,\n 0xfc, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };\n') # noqa: E501
|
|
self.theme_close = tk.BitmapImage(
|
|
data='#define im_width 16\n#define im_height 16\nstatic unsigned char im_bits[] = {\n 0x00, 0x00, 0x00, 0x00, 0x0c, 0x30, 0x1c, 0x38, 0x38, 0x1c, 0x70, 0x0e,\n 0xe0, 0x07, 0xc0, 0x03, 0xc0, 0x03, 0xe0, 0x07, 0x70, 0x0e, 0x38, 0x1c,\n 0x1c, 0x38, 0x0c, 0x30, 0x00, 0x00, 0x00, 0x00 };\n') # noqa: E501
|
|
|
|
frame = tk.Frame(self.w, name=appname.lower())
|
|
frame.grid(sticky=tk.NSEW)
|
|
frame.columnconfigure(1, weight=1)
|
|
|
|
self.cmdr_label = tk.Label(frame)
|
|
self.ship_label = tk.Label(frame)
|
|
self.system_label = tk.Label(frame)
|
|
self.station_label = tk.Label(frame)
|
|
|
|
self.cmdr_label.grid(row=1, column=0, sticky=tk.W)
|
|
self.ship_label.grid(row=2, column=0, sticky=tk.W)
|
|
self.system_label.grid(row=3, column=0, sticky=tk.W)
|
|
self.station_label.grid(row=4, column=0, sticky=tk.W)
|
|
|
|
self.cmdr = tk.Label(frame, compound=tk.RIGHT, anchor=tk.W, name='cmdr')
|
|
self.ship = HyperlinkLabel(frame, compound=tk.RIGHT, url=self.shipyard_url, name='ship')
|
|
self.system = HyperlinkLabel(frame, compound=tk.RIGHT, url=self.system_url, popup_copy=True, name='system')
|
|
self.station = HyperlinkLabel(frame, compound=tk.RIGHT, url=self.station_url, name='station')
|
|
|
|
self.cmdr.grid(row=1, column=1, sticky=tk.EW)
|
|
self.ship.grid(row=2, column=1, sticky=tk.EW)
|
|
self.system.grid(row=3, column=1, sticky=tk.EW)
|
|
self.station.grid(row=4, column=1, sticky=tk.EW)
|
|
|
|
for plugin in plug.PLUGINS:
|
|
appitem = plugin.get_app(frame)
|
|
if appitem:
|
|
tk.Frame(frame, highlightthickness=1).grid(columnspan=2, sticky=tk.EW) # separator
|
|
if isinstance(appitem, tuple) and len(appitem) == 2:
|
|
row = frame.grid_size()[1]
|
|
appitem[0].grid(row=row, column=0, sticky=tk.W)
|
|
appitem[1].grid(row=row, column=1, sticky=tk.EW)
|
|
else:
|
|
appitem.grid(columnspan=2, sticky=tk.EW)
|
|
|
|
# Update button in main window
|
|
self.button = ttk.Button(frame, text=_('Update'), width=28, default=tk.ACTIVE, state=tk.DISABLED)
|
|
self.theme_button = tk.Label(frame, width=32 if platform == 'darwin' else 28, state=tk.DISABLED)
|
|
self.status = tk.Label(frame, name='status', anchor=tk.W)
|
|
|
|
row = frame.grid_size()[1]
|
|
self.button.grid(row=row, columnspan=2, sticky=tk.NSEW)
|
|
self.theme_button.grid(row=row, columnspan=2, sticky=tk.NSEW)
|
|
theme.register_alternate((self.button, self.theme_button, self.theme_button),
|
|
{'row': row, 'columnspan': 2, 'sticky': tk.NSEW}) # noqa: E501
|
|
self.status.grid(columnspan=2, sticky=tk.EW)
|
|
self.button.bind('<Button-1>', self.getandsend)
|
|
theme.button_bind(self.theme_button, self.getandsend)
|
|
|
|
for child in frame.winfo_children():
|
|
child.grid_configure(padx=5, pady=(platform != 'win32' or isinstance(child, tk.Frame)) and 2 or 0)
|
|
|
|
self.menubar = tk.Menu()
|
|
if platform == 'darwin':
|
|
# Can't handle (de)iconify if topmost is set, so suppress iconify button
|
|
# http://wiki.tcl.tk/13428 and p15 of
|
|
# https://developer.apple.com/legacy/library/documentation/Carbon/Conceptual/HandlingWindowsControls/windowscontrols.pdf
|
|
root.call('tk::unsupported::MacWindowStyle', 'style', root, 'document', 'closeBox resizable')
|
|
|
|
# https://www.tcl.tk/man/tcl/TkCmd/menu.htm
|
|
self.system_menu = tk.Menu(self.menubar, name='apple')
|
|
self.system_menu.add_command(command=lambda: self.w.call('tk::mac::standardAboutPanel'))
|
|
self.system_menu.add_command(command=lambda: self.updater.checkForUpdates())
|
|
self.menubar.add_cascade(menu=self.system_menu)
|
|
self.file_menu = tk.Menu(self.menubar, name='file')
|
|
self.file_menu.add_command(command=self.save_raw)
|
|
self.menubar.add_cascade(menu=self.file_menu)
|
|
self.edit_menu = tk.Menu(self.menubar, name='edit')
|
|
self.edit_menu.add_command(accelerator='Command-c', state=tk.DISABLED, command=self.copy)
|
|
self.menubar.add_cascade(menu=self.edit_menu)
|
|
self.w.bind('<Command-c>', self.copy)
|
|
self.view_menu = tk.Menu(self.menubar, name='view')
|
|
self.view_menu.add_command(command=lambda: stats.StatsDialog(self))
|
|
self.menubar.add_cascade(menu=self.view_menu)
|
|
window_menu = tk.Menu(self.menubar, name='window')
|
|
self.menubar.add_cascade(menu=window_menu)
|
|
self.help_menu = tk.Menu(self.menubar, name='help')
|
|
self.w.createcommand("::tk::mac::ShowHelp", self.help_general)
|
|
self.help_menu.add_command(command=self.help_privacy)
|
|
self.help_menu.add_command(command=self.help_releases)
|
|
self.menubar.add_cascade(menu=self.help_menu)
|
|
self.w['menu'] = self.menubar
|
|
# https://www.tcl.tk/man/tcl/TkCmd/tk_mac.htm
|
|
self.w.call('set', 'tk::mac::useCompatibilityMetrics', '0')
|
|
self.w.createcommand('tkAboutDialog', lambda: self.w.call('tk::mac::standardAboutPanel'))
|
|
self.w.createcommand("::tk::mac::Quit", self.onexit)
|
|
self.w.createcommand("::tk::mac::ShowPreferences", lambda: prefs.PreferencesDialog(self.w, self.postprefs))
|
|
self.w.createcommand("::tk::mac::ReopenApplication", self.w.deiconify) # click on app in dock = restore
|
|
self.w.protocol("WM_DELETE_WINDOW", self.w.withdraw) # close button shouldn't quit app
|
|
self.w.resizable(tk.FALSE, tk.FALSE) # Can't be only resizable on one axis
|
|
else:
|
|
self.file_menu = self.view_menu = tk.Menu(self.menubar, tearoff=tk.FALSE)
|
|
self.file_menu.add_command(command=lambda: stats.StatsDialog(self))
|
|
self.file_menu.add_command(command=self.save_raw)
|
|
self.file_menu.add_command(command=lambda: prefs.PreferencesDialog(self.w, self.postprefs))
|
|
self.file_menu.add_separator()
|
|
self.file_menu.add_command(command=self.onexit)
|
|
self.menubar.add_cascade(menu=self.file_menu)
|
|
self.edit_menu = tk.Menu(self.menubar, tearoff=tk.FALSE)
|
|
self.edit_menu.add_command(accelerator='Ctrl+C', state=tk.DISABLED, command=self.copy)
|
|
self.menubar.add_cascade(menu=self.edit_menu)
|
|
self.help_menu = tk.Menu(self.menubar, tearoff=tk.FALSE)
|
|
self.help_menu.add_command(command=self.help_general)
|
|
self.help_menu.add_command(command=self.help_privacy)
|
|
self.help_menu.add_command(command=self.help_releases)
|
|
self.help_menu.add_command(command=lambda: self.updater.checkForUpdates())
|
|
self.help_menu.add_command(command=lambda: not self.HelpAbout.showing and self.HelpAbout(self.w))
|
|
|
|
self.menubar.add_cascade(menu=self.help_menu)
|
|
if platform == 'win32':
|
|
# Must be added after at least one "real" menu entry
|
|
self.always_ontop = tk.BooleanVar(value=config.getint('always_ontop'))
|
|
self.system_menu = tk.Menu(self.menubar, name='system', tearoff=tk.FALSE)
|
|
self.system_menu.add_separator()
|
|
self.system_menu.add_checkbutton(label=_('Always on top'),
|
|
variable=self.always_ontop,
|
|
command=self.ontop_changed) # Appearance setting
|
|
self.menubar.add_cascade(menu=self.system_menu)
|
|
self.w.bind('<Control-c>', self.copy)
|
|
self.w.protocol("WM_DELETE_WINDOW", self.onexit)
|
|
theme.register(self.menubar) # menus and children aren't automatically registered
|
|
theme.register(self.file_menu)
|
|
theme.register(self.edit_menu)
|
|
theme.register(self.help_menu)
|
|
|
|
# Alternate title bar and menu for dark theme
|
|
self.theme_menubar = tk.Frame(frame)
|
|
self.theme_menubar.columnconfigure(2, weight=1)
|
|
theme_titlebar = tk.Label(self.theme_menubar, text=applongname,
|
|
image=self.theme_icon, cursor='fleur',
|
|
anchor=tk.W, compound=tk.LEFT)
|
|
theme_titlebar.grid(columnspan=3, padx=2, sticky=tk.NSEW)
|
|
self.drag_offset = None
|
|
theme_titlebar.bind('<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=5, sticky=tk.W)
|
|
theme.button_bind(self.theme_file_menu,
|
|
lambda e: self.file_menu.tk_popup(e.widget.winfo_rootx(),
|
|
e.widget.winfo_rooty()
|
|
+ e.widget.winfo_height()))
|
|
self.theme_edit_menu = tk.Label(self.theme_menubar, anchor=tk.W)
|
|
self.theme_edit_menu.grid(row=1, column=1, sticky=tk.W)
|
|
theme.button_bind(self.theme_edit_menu,
|
|
lambda e: self.edit_menu.tk_popup(e.widget.winfo_rootx(),
|
|
e.widget.winfo_rooty()
|
|
+ e.widget.winfo_height()))
|
|
self.theme_help_menu = tk.Label(self.theme_menubar, anchor=tk.W)
|
|
self.theme_help_menu.grid(row=1, column=2, sticky=tk.W)
|
|
theme.button_bind(self.theme_help_menu,
|
|
lambda e: self.help_menu.tk_popup(e.widget.winfo_rootx(),
|
|
e.widget.winfo_rooty()
|
|
+ e.widget.winfo_height()))
|
|
tk.Frame(self.theme_menubar, highlightthickness=1).grid(columnspan=5, padx=5, sticky=tk.EW)
|
|
theme.register(self.theme_minimize) # images aren't automatically registered
|
|
theme.register(self.theme_close)
|
|
self.blank_menubar = tk.Frame(frame)
|
|
tk.Label(self.blank_menubar).grid()
|
|
tk.Label(self.blank_menubar).grid()
|
|
tk.Frame(self.blank_menubar, height=2).grid()
|
|
theme.register_alternate((self.menubar, self.theme_menubar, self.blank_menubar),
|
|
{'row': 0, 'columnspan': 2, 'sticky': tk.NSEW})
|
|
self.w.resizable(tk.TRUE, tk.FALSE)
|
|
|
|
# update geometry
|
|
if config.get('geometry'):
|
|
match = re.match('\+([\-\d]+)\+([\-\d]+)', config.get('geometry')) # noqa: W605
|
|
if match:
|
|
if platform == 'darwin':
|
|
# http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7
|
|
if int(match.group(2)) >= 0:
|
|
self.w.geometry(config.get('geometry'))
|
|
elif platform == 'win32':
|
|
# Check that the titlebar will be at least partly on screen
|
|
import ctypes
|
|
from ctypes.wintypes import POINT
|
|
|
|
# https://msdn.microsoft.com/en-us/library/dd145064
|
|
MONITOR_DEFAULTTONULL = 0 # noqa: N806
|
|
if ctypes.windll.user32.MonitorFromPoint(POINT(int(match.group(1)) + 16, int(match.group(2)) + 16),
|
|
MONITOR_DEFAULTTONULL):
|
|
self.w.geometry(config.get('geometry'))
|
|
else:
|
|
self.w.geometry(config.get('geometry'))
|
|
self.w.attributes('-topmost', config.getint('always_ontop') and 1 or 0)
|
|
|
|
theme.register(frame)
|
|
theme.apply(self.w)
|
|
|
|
self.w.bind('<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.getandsend)
|
|
self.w.bind('<KP_Enter>', self.getandsend)
|
|
self.w.bind_all('<<Invoke>>', self.getandsend) # Hotkey monitoring
|
|
self.w.bind_all('<<JournalEvent>>', self.journal_event) # 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
|
|
|
|
# Start a protocol handler to handle cAPI registration. Requires main loop to be running.
|
|
self.w.after_idle(lambda: protocolhandler.start(self.w))
|
|
|
|
# Load updater after UI creation (for WinSparkle)
|
|
import update
|
|
|
|
if getattr(sys, 'frozen', False):
|
|
# Running in frozen .exe, so use (Win)Sparkle
|
|
self.updater = update.Updater(tkroot=self.w, provider='external')
|
|
else:
|
|
self.updater = update.Updater(tkroot=self.w, provider='internal')
|
|
self.updater.checkForUpdates() # Sparkle / WinSparkle does this automatically for packaged apps
|
|
|
|
try:
|
|
config.get_password('') # Prod SecureStorage on Linux to initialise
|
|
except RuntimeError:
|
|
pass
|
|
|
|
# Migration from <= 3.30
|
|
for username in config.get('fdev_usernames') or []:
|
|
config.delete_password(username)
|
|
config.delete('fdev_usernames')
|
|
config.delete('username')
|
|
config.delete('password')
|
|
config.delete('logdir')
|
|
|
|
self.postprefs(False) # Companion login happens in callback from monitor
|
|
|
|
# callback after the Preferences dialog is applied
|
|
def postprefs(self, dologin=True):
|
|
self.prefsdialog = None
|
|
self.set_labels() # in case language has changed
|
|
|
|
# Reset links in case plugins changed them
|
|
self.ship.configure(url=self.shipyard_url)
|
|
self.system.configure(url=self.system_url)
|
|
self.station.configure(url=self.station_url)
|
|
|
|
# (Re-)install hotkey monitoring
|
|
hotkeymgr.register(self.w, config.getint('hotkey_code'), config.getint('hotkey_mods'))
|
|
|
|
# Update Journal lock if needs be.
|
|
journal_lock.update_lock(self.w)
|
|
|
|
# (Re-)install log monitoring
|
|
if not monitor.start(self.w):
|
|
self.status['text'] = f'Error: Check {_("E:D journal file location")}'
|
|
|
|
if dologin and monitor.cmdr:
|
|
self.login() # Login if not already logged in with this Cmdr
|
|
|
|
# set main window labels, e.g. after language change
|
|
def set_labels(self):
|
|
self.cmdr_label['text'] = _('Cmdr') + ':' # Main window
|
|
# Multicrew role label in main window
|
|
self.ship_label['text'] = (monitor.state['Captain'] and _('Role') or _('Ship')) + ':' # Main window
|
|
self.system_label['text'] = _('System') + ':' # Main window
|
|
self.station_label['text'] = _('Station') + ':' # Main window
|
|
self.button['text'] = self.theme_button['text'] = _('Update') # Update button in main window
|
|
if platform == 'darwin':
|
|
self.menubar.entryconfigure(1, label=_('File')) # Menu title
|
|
self.menubar.entryconfigure(2, label=_('Edit')) # Menu title
|
|
self.menubar.entryconfigure(3, label=_('View')) # Menu title on OSX
|
|
self.menubar.entryconfigure(4, label=_('Window')) # Menu title on OSX
|
|
self.menubar.entryconfigure(5, label=_('Help')) # Menu title
|
|
self.system_menu.entryconfigure(0, label=_("About {APP}").format(APP=applongname)) # App menu entry on OSX
|
|
self.system_menu.entryconfigure(1, label=_("Check for Updates...")) # Menu item
|
|
self.file_menu.entryconfigure(0, label=_('Save Raw Data...')) # Menu item
|
|
self.view_menu.entryconfigure(0, label=_('Status')) # Menu item
|
|
self.help_menu.entryconfigure(1, label=_('Privacy Policy')) # Help menu item
|
|
self.help_menu.entryconfigure(2, label=_('Release Notes')) # Help menu item
|
|
else:
|
|
self.menubar.entryconfigure(1, label=_('File')) # Menu title
|
|
self.menubar.entryconfigure(2, label=_('Edit')) # Menu title
|
|
self.menubar.entryconfigure(3, label=_('Help')) # Menu title
|
|
self.theme_file_menu['text'] = _('File') # Menu title
|
|
self.theme_edit_menu['text'] = _('Edit') # Menu title
|
|
self.theme_help_menu['text'] = _('Help') # Menu title
|
|
|
|
# File menu
|
|
self.file_menu.entryconfigure(0, label=_('Status')) # Menu item
|
|
self.file_menu.entryconfigure(1, label=_('Save Raw Data...')) # Menu item
|
|
self.file_menu.entryconfigure(2, label=_('Settings')) # Item in the File menu on Windows
|
|
self.file_menu.entryconfigure(4, label=_('Exit')) # Item in the File menu on Windows
|
|
|
|
# Help menu
|
|
self.help_menu.entryconfigure(0, label=_('Documentation')) # Help menu item
|
|
self.help_menu.entryconfigure(1, label=_('Privacy Policy')) # Help menu item
|
|
self.help_menu.entryconfigure(2, label=_('Release Notes')) # Help menu item
|
|
self.help_menu.entryconfigure(3, label=_('Check for Updates...')) # Menu item
|
|
self.help_menu.entryconfigure(4, label=_("About {APP}").format(APP=applongname)) # App menu entry
|
|
|
|
# Edit menu
|
|
self.edit_menu.entryconfigure(0, label=_('Copy')) # As in Copy and Paste
|
|
|
|
def login(self):
|
|
if not self.status['text']:
|
|
self.status['text'] = _('Logging in...')
|
|
self.button['state'] = self.theme_button['state'] = tk.DISABLED
|
|
if platform == 'darwin':
|
|
self.view_menu.entryconfigure(0, state=tk.DISABLED) # Status
|
|
self.file_menu.entryconfigure(0, state=tk.DISABLED) # Save Raw Data
|
|
else:
|
|
self.file_menu.entryconfigure(0, state=tk.DISABLED) # Status
|
|
self.file_menu.entryconfigure(1, state=tk.DISABLED) # Save Raw Data
|
|
self.w.update_idletasks()
|
|
try:
|
|
if companion.session.login(monitor.cmdr, monitor.is_beta):
|
|
# Successfully authenticated with the Frontier website
|
|
self.status['text'] = _('Authentication successful')
|
|
if platform == 'darwin':
|
|
self.view_menu.entryconfigure(0, state=tk.NORMAL) # Status
|
|
self.file_menu.entryconfigure(0, state=tk.NORMAL) # Save Raw Data
|
|
else:
|
|
self.file_menu.entryconfigure(0, state=tk.NORMAL) # Status
|
|
self.file_menu.entryconfigure(1, state=tk.NORMAL) # Save Raw Data
|
|
except (companion.CredentialsError, companion.ServerError, companion.ServerLagging) as e:
|
|
self.status['text'] = str(e)
|
|
except Exception as e:
|
|
logger.debug('Frontier CAPI Auth', exc_info=e)
|
|
self.status['text'] = str(e)
|
|
self.cooldown()
|
|
|
|
def getandsend(self, event=None, retrying=False): # noqa: C901
|
|
|
|
auto_update = not event
|
|
play_sound = (auto_update or int(event.type) == self.EVENT_VIRTUAL) and not config.getint('hotkey_mute')
|
|
play_bad = False
|
|
|
|
if not monitor.cmdr or not monitor.mode or monitor.state['Captain'] or not monitor.system:
|
|
return # In CQC or on crew - do nothing
|
|
|
|
if companion.session.state == companion.Session.STATE_AUTH:
|
|
# Attempt another Auth
|
|
self.login()
|
|
return
|
|
|
|
if not retrying:
|
|
if time() < self.holdofftime: # Was invoked by key while in cooldown
|
|
self.status['text'] = ''
|
|
if play_sound and (self.holdofftime - time()) < companion.holdoff * 0.75:
|
|
hotkeymgr.play_bad() # Don't play sound in first few seconds to prevent repeats
|
|
return
|
|
elif play_sound:
|
|
hotkeymgr.play_good()
|
|
self.status['text'] = _('Fetching data...')
|
|
self.button['state'] = self.theme_button['state'] = tk.DISABLED
|
|
self.w.update_idletasks()
|
|
|
|
try:
|
|
querytime = int(time())
|
|
data = companion.session.station()
|
|
config.set('querytime', querytime)
|
|
|
|
# Validation
|
|
if 'commander' not in data:
|
|
# This can happen with EGS Auth if no commander created yet
|
|
self.status['text'] = _('CAPI: No commander data returned')
|
|
|
|
elif not data.get('commander', {}).get('name'):
|
|
self.status['text'] = _("Who are you?!") # Shouldn't happen
|
|
|
|
elif (not data.get('lastSystem', {}).get('name')
|
|
or (data['commander'].get('docked')
|
|
and not data.get('lastStarport', {}).get('name'))): # Only care if docked
|
|
self.status['text'] = _("Where are you?!") # Shouldn't happen
|
|
|
|
elif not data.get('ship', {}).get('name') or not data.get('ship', {}).get('modules'):
|
|
self.status['text'] = _("What are you flying?!") # Shouldn't happen
|
|
|
|
elif monitor.cmdr and data['commander']['name'] != monitor.cmdr:
|
|
# Companion API return doesn't match Journal
|
|
raise companion.CmdrError()
|
|
|
|
elif ((auto_update and not data['commander'].get('docked'))
|
|
or (data['lastSystem']['name'] != monitor.system)
|
|
or ((data['commander']['docked']
|
|
and data['lastStarport']['name'] or None) != monitor.station)
|
|
or (data['ship']['id'] != monitor.state['ShipID'])
|
|
or (data['ship']['name'].lower() != monitor.state['ShipType'])):
|
|
raise companion.ServerLagging()
|
|
|
|
else:
|
|
|
|
if __debug__: # Recording
|
|
if isdir('dump'):
|
|
with open('dump/{system}{station}.{timestamp}.json'.format(
|
|
system=data['lastSystem']['name'],
|
|
station=data['commander'].get('docked') and '.' + data['lastStarport']['name'] or '',
|
|
timestamp=strftime('%Y-%m-%dT%H.%M.%S', localtime())), 'wb') as h:
|
|
h.write(json.dumps(dict(data),
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
sort_keys=True,
|
|
separators=(',', ': ')).encode('utf-8'))
|
|
|
|
if not monitor.state['ShipType']: # Started game in SRV or fighter
|
|
self.ship['text'] = companion.ship_map.get(data['ship']['name'].lower(), data['ship']['name'])
|
|
monitor.state['ShipID'] = data['ship']['id']
|
|
monitor.state['ShipType'] = data['ship']['name'].lower()
|
|
|
|
if data['commander'].get('credits') is not None:
|
|
monitor.state['Credits'] = data['commander']['credits']
|
|
monitor.state['Loan'] = data['commander'].get('debt', 0)
|
|
|
|
# stuff we can do when not docked
|
|
err = plug.notify_newdata(data, monitor.is_beta)
|
|
self.status['text'] = err and err or ''
|
|
if err:
|
|
play_bad = True
|
|
|
|
# Export market data
|
|
if config.getint('output') & (config.OUT_STATION_ANY):
|
|
if not data['commander'].get('docked'):
|
|
if not self.status['text']:
|
|
# Signal as error because the user might actually be docked
|
|
# but the server hosting the Companion API hasn't caught up
|
|
self.status['text'] = _("You're not docked at a station!")
|
|
play_bad = True
|
|
# Ignore possibly missing shipyard info
|
|
elif (config.getint('output') & config.OUT_MKT_EDDN) \
|
|
and not (data['lastStarport'].get('commodities') or data['lastStarport'].get('modules')):
|
|
if not self.status['text']:
|
|
self.status['text'] = _("Station doesn't have anything!")
|
|
elif not data['lastStarport'].get('commodities'):
|
|
if not self.status['text']:
|
|
self.status['text'] = _("Station doesn't have a market!")
|
|
elif config.getint('output') & (config.OUT_MKT_CSV | config.OUT_MKT_TD):
|
|
# Fixup anomalies in the commodity data
|
|
fixed = companion.fixup(data)
|
|
if config.getint('output') & config.OUT_MKT_CSV:
|
|
commodity.export(fixed, COMMODITY_CSV)
|
|
if config.getint('output') & config.OUT_MKT_TD:
|
|
td.export(fixed)
|
|
|
|
self.holdofftime = querytime + companion.holdoff
|
|
|
|
# Companion API problem
|
|
except companion.ServerLagging as e:
|
|
if retrying:
|
|
self.status['text'] = str(e)
|
|
play_bad = True
|
|
else:
|
|
# Retry once if Companion server is unresponsive
|
|
self.w.after(int(SERVER_RETRY * 1000), lambda: self.getandsend(event, True))
|
|
return # early exit to avoid starting cooldown count
|
|
|
|
except companion.CmdrError as e: # Companion API return doesn't match Journal
|
|
self.status['text'] = str(e)
|
|
play_bad = True
|
|
companion.session.invalidate()
|
|
self.login()
|
|
|
|
except Exception as e: # Including CredentialsError, ServerError
|
|
logger.debug('"other" exception', exc_info=e)
|
|
self.status['text'] = str(e)
|
|
play_bad = True
|
|
|
|
if not self.status['text']: # no errors
|
|
self.status['text'] = strftime(_('Last updated at %H:%M:%S'), localtime(querytime))
|
|
if play_sound and play_bad:
|
|
hotkeymgr.play_bad()
|
|
|
|
self.cooldown()
|
|
|
|
def retry_for_shipyard(self, tries):
|
|
# Try again to get shipyard data and send to EDDN. Don't report errors if can't get or send the data.
|
|
try:
|
|
data = companion.session.station()
|
|
if data['commander'].get('docked'):
|
|
if data.get('lastStarport', {}).get('ships'):
|
|
report = 'Success'
|
|
else:
|
|
report = 'Failure'
|
|
else:
|
|
report = 'Undocked!'
|
|
logger.debug(f'Retry for shipyard - {report}')
|
|
if not data['commander'].get('docked'):
|
|
# might have un-docked while we were waiting for retry in which case station data is unreliable
|
|
pass
|
|
elif (data.get('lastSystem', {}).get('name') == monitor.system and
|
|
data.get('lastStarport', {}).get('name') == monitor.station and
|
|
data.get('lastStarport', {}).get('ships', {}).get('shipyard_list')):
|
|
self.eddn.export_shipyard(data, monitor.is_beta)
|
|
elif tries > 1: # bogus data - retry
|
|
self.w.after(int(SERVER_RETRY * 1000), lambda: self.retry_for_shipyard(tries - 1))
|
|
except Exception:
|
|
pass
|
|
|
|
# Handle event(s) from the journal
|
|
def journal_event(self, event: str): # noqa: C901
|
|
"""
|
|
Handle a Journal event passed through event queue from monitor.py.
|
|
|
|
:param event: string JSON data of the event
|
|
:return:
|
|
"""
|
|
|
|
def crewroletext(role):
|
|
# Return translated crew role. Needs to be dynamic to allow for changing language.
|
|
return {
|
|
None: '',
|
|
'Idle': '',
|
|
'FighterCon': _('Fighter'), # Multicrew role
|
|
'FireCon': _('Gunner'), # Multicrew role
|
|
'FlightCon': _('Helm'), # Multicrew role
|
|
}.get(role, role)
|
|
|
|
if monitor.thread is None:
|
|
logger.debug('monitor.thread is None, assuming shutdown and returning')
|
|
return
|
|
|
|
while True:
|
|
entry = monitor.get_entry()
|
|
if not entry:
|
|
logger.trace('No entry from monitor.get_entry()')
|
|
return
|
|
|
|
# Update main window
|
|
self.cooldown()
|
|
if monitor.cmdr and monitor.state['Captain']:
|
|
self.cmdr['text'] = f'{monitor.cmdr} / {monitor.state["Captain"]}'
|
|
self.ship_label['text'] = _('Role') + ':' # Multicrew role label in main window
|
|
self.ship.configure(state=tk.NORMAL, text=crewroletext(monitor.state['Role']), url=None)
|
|
elif monitor.cmdr:
|
|
if monitor.group:
|
|
self.cmdr['text'] = f'{monitor.cmdr} / {monitor.group}'
|
|
else:
|
|
self.cmdr['text'] = monitor.cmdr
|
|
self.ship_label['text'] = _('Ship') + ':' # Main window
|
|
self.ship.configure(
|
|
text=monitor.state['ShipName']
|
|
or companion.ship_map.get(monitor.state['ShipType'], monitor.state['ShipType'])
|
|
or '',
|
|
url=self.shipyard_url)
|
|
else:
|
|
self.cmdr['text'] = ''
|
|
self.ship_label['text'] = _('Ship') + ':' # Main window
|
|
self.ship['text'] = ''
|
|
|
|
self.edit_menu.entryconfigure(0, state=monitor.system and tk.NORMAL or tk.DISABLED) # Copy
|
|
|
|
if entry['event'] in (
|
|
'Undocked',
|
|
'StartJump',
|
|
'SetUserShipName',
|
|
'ShipyardBuy',
|
|
'ShipyardSell',
|
|
'ShipyardSwap',
|
|
'ModuleBuy',
|
|
'ModuleSell',
|
|
'MaterialCollected',
|
|
'MaterialDiscarded',
|
|
'ScientificResearch',
|
|
'EngineerCraft',
|
|
'Synthesis',
|
|
'JoinACrew'):
|
|
self.status['text'] = '' # Periodically clear any old error
|
|
self.w.update_idletasks()
|
|
|
|
# Companion login
|
|
if entry['event'] in [None, 'StartUp', 'NewCommander', 'LoadGame'] and monitor.cmdr:
|
|
if not config.get('cmdrs') or monitor.cmdr not in config.get('cmdrs'):
|
|
config.set('cmdrs', (config.get('cmdrs') or []) + [monitor.cmdr])
|
|
self.login()
|
|
|
|
if not entry['event'] or not monitor.mode:
|
|
logger.trace('Startup or in CQC, returning')
|
|
return # Startup or in CQC
|
|
|
|
if entry['event'] in ['StartUp', 'LoadGame'] and monitor.started:
|
|
logger.info('Startup or LoadGame event')
|
|
# Disable WinSparkle automatic update checks, IFF configured to do so when in-game
|
|
if config.getint('disable_autoappupdatecheckingame') and 1:
|
|
self.updater.setAutomaticUpdatesCheck(False)
|
|
logger.info('Monitor: Disable WinSparkle automatic update checks')
|
|
# Can start dashboard monitoring
|
|
if not dashboard.start(self.w, monitor.started):
|
|
logger.info("Can't start Status monitoring")
|
|
|
|
# Export loadout
|
|
if entry['event'] == 'Loadout' and not monitor.state['Captain'] \
|
|
and config.getint('output') & config.OUT_SHIP:
|
|
monitor.export_ship()
|
|
|
|
# Plugins
|
|
err = plug.notify_journal_entry(monitor.cmdr,
|
|
monitor.is_beta,
|
|
monitor.system,
|
|
monitor.station,
|
|
entry,
|
|
monitor.state)
|
|
if err:
|
|
self.status['text'] = err
|
|
if not config.getint('hotkey_mute'):
|
|
hotkeymgr.play_bad()
|
|
|
|
# Auto-Update after docking, but not if auth callback is pending
|
|
if entry['event'] in ('StartUp', 'Location', 'Docked') \
|
|
and monitor.station \
|
|
and not config.getint('output') & config.OUT_MKT_MANUAL \
|
|
and config.getint('output') & config.OUT_STATION_ANY \
|
|
and companion.session.state != companion.Session.STATE_AUTH:
|
|
self.w.after(int(SERVER_RETRY * 1000), self.getandsend)
|
|
|
|
if entry['event'] == 'ShutDown':
|
|
# Enable WinSparkle automatic update checks
|
|
# NB: Do this blindly, in case option got changed whilst in-game
|
|
self.updater.setAutomaticUpdatesCheck(True)
|
|
logger.info('Monitor: Enable WinSparkle automatic update checks')
|
|
|
|
# cAPI auth
|
|
def auth(self, event=None):
|
|
logger.debug('Received "<<CompanionAuthEvent>>')
|
|
try:
|
|
companion.session.auth_callback()
|
|
# Successfully authenticated with the Frontier website
|
|
self.status['text'] = _('Authentication successful')
|
|
if platform == 'darwin':
|
|
self.view_menu.entryconfigure(0, state=tk.NORMAL) # Status
|
|
self.file_menu.entryconfigure(0, state=tk.NORMAL) # Save Raw Data
|
|
|
|
else:
|
|
self.file_menu.entryconfigure(0, state=tk.NORMAL) # Status
|
|
self.file_menu.entryconfigure(1, state=tk.NORMAL) # Save Raw Data
|
|
|
|
except companion.ServerError as e:
|
|
self.status['text'] = str(e)
|
|
|
|
except Exception as e:
|
|
logger.debug('Frontier CAPI Auth:', exc_info=e)
|
|
self.status['text'] = str(e)
|
|
|
|
self.cooldown()
|
|
|
|
# Handle Status event
|
|
def dashboard_event(self, event):
|
|
entry = dashboard.status
|
|
if entry:
|
|
# Currently we don't do anything with these events
|
|
err = plug.notify_dashboard_entry(monitor.cmdr, monitor.is_beta, entry)
|
|
if err:
|
|
self.status['text'] = err
|
|
if not config.getint('hotkey_mute'):
|
|
hotkeymgr.play_bad()
|
|
|
|
# Display asynchronous error from plugin
|
|
def plugin_error(self, event=None):
|
|
if plug.last_error.get('msg'):
|
|
self.status['text'] = plug.last_error['msg']
|
|
self.w.update_idletasks()
|
|
if not config.getint('hotkey_mute'):
|
|
hotkeymgr.play_bad()
|
|
|
|
def shipyard_url(self, shipname):
|
|
if not bool(config.getint("use_alt_shipyard_open")):
|
|
return plug.invoke(config.get('shipyard_provider'), 'EDSY', 'shipyard_url', monitor.ship(), monitor.is_beta)
|
|
|
|
# Avoid file length limits if possible
|
|
provider = config.get('shipyard_provider') or 'EDSY'
|
|
target = plug.invoke(config.get('shipyard_provider'), 'EDSY', 'shipyard_url', monitor.ship(), monitor.is_beta)
|
|
file_name = join(config.app_dir, "last_shipyard.html")
|
|
|
|
with open(file_name, 'w') as f:
|
|
print(SHIPYARD_HTML_TEMPLATE.format(
|
|
link=html.escape(str(target)),
|
|
provider_name=html.escape(str(provider)),
|
|
ship_name=html.escape(str(shipname))
|
|
), file=f)
|
|
|
|
return f'file://localhost/{file_name}'
|
|
|
|
def system_url(self, system):
|
|
return plug.invoke(config.get('system_provider'), 'EDSM', 'system_url', monitor.system)
|
|
|
|
def station_url(self, station):
|
|
return plug.invoke(config.get('station_provider'), 'eddb', 'station_url', monitor.system, monitor.station)
|
|
|
|
def cooldown(self):
|
|
if time() < self.holdofftime:
|
|
# Update button in main window
|
|
self.button['text'] = self.theme_button['text'] \
|
|
= _('cooldown {SS}s').format(SS=int(self.holdofftime - time()))
|
|
self.w.after(1000, self.cooldown)
|
|
else:
|
|
self.button['text'] = self.theme_button['text'] = _('Update') # Update button in main window
|
|
self.button['state'] = self.theme_button['state'] = (monitor.cmdr and
|
|
monitor.mode and
|
|
not monitor.state['Captain'] and
|
|
monitor.system and
|
|
tk.NORMAL or tk.DISABLED)
|
|
|
|
def ontop_changed(self, event=None):
|
|
config.set('always_ontop', self.always_ontop.get())
|
|
self.w.wm_attributes('-topmost', self.always_ontop.get())
|
|
|
|
def copy(self, event=None):
|
|
if monitor.system:
|
|
self.w.clipboard_clear()
|
|
self.w.clipboard_append(monitor.station and f'{monitor.system},{monitor.station}' or monitor.system)
|
|
|
|
def help_general(self, event=None):
|
|
webbrowser.open('https://github.com/EDCD/EDMarketConnector/wiki')
|
|
|
|
def help_privacy(self, event=None):
|
|
webbrowser.open('https://github.com/EDCD/EDMarketConnector/wiki/Privacy-Policy')
|
|
|
|
def help_releases(self, event=None):
|
|
webbrowser.open('https://github.com/EDCD/EDMarketConnector/releases')
|
|
|
|
class HelpAbout(tk.Toplevel):
|
|
showing = False
|
|
|
|
def __init__(self, parent):
|
|
if self.__class__.showing:
|
|
return
|
|
self.__class__.showing = True
|
|
|
|
tk.Toplevel.__init__(self, parent)
|
|
|
|
self.parent = parent
|
|
self.title(_('About {APP}').format(APP=applongname))
|
|
|
|
if parent.winfo_viewable():
|
|
self.transient(parent)
|
|
|
|
# position over parent
|
|
# http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7
|
|
if platform != 'darwin' or parent.winfo_rooty() > 0:
|
|
self.geometry(f'+{parent.winfo_rootx():d}+{parent.winfo_rooty():d}')
|
|
|
|
# remove decoration
|
|
if platform == 'win32':
|
|
self.attributes('-toolwindow', tk.TRUE)
|
|
|
|
self.resizable(tk.FALSE, tk.FALSE)
|
|
|
|
frame = ttk.Frame(self)
|
|
frame.grid(sticky=tk.NSEW)
|
|
|
|
row = 1
|
|
############################################################
|
|
# applongname
|
|
self.appname_label = tk.Label(frame, text=applongname)
|
|
self.appname_label.grid(row=row, columnspan=3, sticky=tk.EW)
|
|
row += 1
|
|
############################################################
|
|
|
|
############################################################
|
|
# version <link to changelog>
|
|
ttk.Label(frame).grid(row=row, column=0) # spacer
|
|
row += 1
|
|
self.appversion_label = tk.Label(frame, text=appversion)
|
|
self.appversion_label.grid(row=row, column=0, sticky=tk.E)
|
|
self.appversion = HyperlinkLabel(frame, compound=tk.RIGHT, text=_('Release Notes'),
|
|
url='https://github.com/EDCD/EDMarketConnector/releases/tag/Release/'
|
|
f'{appversion_nobuild}',
|
|
underline=True)
|
|
self.appversion.grid(row=row, column=2, sticky=tk.W)
|
|
row += 1
|
|
############################################################
|
|
|
|
############################################################
|
|
# <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
|
|
button = ttk.Button(frame, text=_('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):
|
|
self._destroy()
|
|
|
|
def _destroy(self):
|
|
self.parent.wm_attributes('-topmost', config.getint('always_ontop') and 1 or 0)
|
|
self.destroy()
|
|
self.__class__.showing = False
|
|
|
|
def save_raw(self):
|
|
self.status['text'] = _('Fetching data...')
|
|
self.w.update_idletasks()
|
|
|
|
try:
|
|
data = companion.session.station()
|
|
self.status['text'] = ''
|
|
default_extension: str = ''
|
|
if platform == 'darwin':
|
|
default_extension = '.json'
|
|
last_system: str = data.get("lastSystem", {}).get("name", "Unknown")
|
|
last_starport: str = ''
|
|
if data['commander'].get('docked'):
|
|
last_starport = '.' + data.get('lastStarport', {}).get('name', 'Unknown')
|
|
timestamp: str = strftime('%Y-%m-%dT%H.%M.%S', localtime())
|
|
f = tkinter.filedialog.asksaveasfilename(parent=self.w,
|
|
defaultextension=default_extension,
|
|
filetypes=[('JSON', '.json'), ('All Files', '*')],
|
|
initialdir=config.get('outdir'),
|
|
initialfile=f'{last_system}{last_starport}.{timestamp}')
|
|
if f:
|
|
with open(f, 'wb') as h:
|
|
h.write(json.dumps(dict(data),
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
sort_keys=True,
|
|
separators=(',', ': ')).encode('utf-8'))
|
|
except companion.ServerError as e:
|
|
self.status['text'] = str(e)
|
|
except Exception as e:
|
|
logger.debug('"other" exception', exc_info=e)
|
|
self.status['text'] = str(e)
|
|
|
|
def onexit(self, event=None):
|
|
config.set_shutdown() # Signal we're in shutdown now.
|
|
|
|
# http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7
|
|
if platform != 'darwin' or self.w.winfo_rooty() > 0:
|
|
x, y = self.w.geometry().split('+')[1:3] # e.g. '212x170+2881+1267'
|
|
config.set('geometry', f'+{x}+{y}')
|
|
|
|
# Let the user know we're shutting down.
|
|
self.status['text'] = _('Shutting down...')
|
|
self.w.update_idletasks()
|
|
logger.info('Starting shutdown procedures...')
|
|
|
|
# First so it doesn't interrupt us
|
|
logger.info('Closing update checker...')
|
|
self.updater.close()
|
|
|
|
# Earlier than anything else so plugin code can't interfere *and* it
|
|
# won't still be running in a manner that might rely on something
|
|
# we'd otherwise have already stopped.
|
|
logger.info('Notifying plugins to stop...')
|
|
plug.notify_stop()
|
|
|
|
# Handling of application hotkeys now so the user can't possible cause
|
|
# an issue via triggering one.
|
|
logger.info('Unregistering hotkey manager...')
|
|
hotkeymgr.unregister()
|
|
|
|
# Now the main programmatic input methods
|
|
logger.info('Closing dashboard...')
|
|
dashboard.close()
|
|
|
|
logger.info('Closing journal monitor...')
|
|
monitor.close()
|
|
|
|
# Frontier auth/CAPI handling
|
|
logger.info('Closing protocol handler...')
|
|
protocolhandler.close()
|
|
|
|
logger.info('Closing Frontier CAPI sessions...')
|
|
companion.session.close()
|
|
|
|
# Now anything else.
|
|
logger.info('Closing config...')
|
|
config.close()
|
|
|
|
logger.info('Destroying app window...')
|
|
self.w.destroy()
|
|
|
|
logger.info('Done.')
|
|
|
|
def drag_start(self, event) -> None:
|
|
"""Initiate dragging the window."""
|
|
self.drag_offset = (event.x_root - self.w.winfo_rootx(), event.y_root - self.w.winfo_rooty())
|
|
|
|
def drag_continue(self, event):
|
|
if self.drag_offset:
|
|
offset_x = event.x_root - self.drag_offset[0]
|
|
offset_y = event.y_root - self.drag_offset[1]
|
|
self.w.geometry(f'+{offset_x:d}+{offset_y:d}')
|
|
|
|
def drag_end(self, event):
|
|
self.drag_offset = None
|
|
|
|
def oniconify(self, event=None):
|
|
self.w.overrideredirect(0) # Can't iconize while overrideredirect
|
|
self.w.iconify()
|
|
self.w.update_idletasks() # Size and windows styles get recalculated here
|
|
self.w.wait_visibility() # Need main window to be re-created before returning
|
|
theme.active = None # So theme will be re-applied on map
|
|
|
|
def onmap(self, event=None):
|
|
if event.widget == self.w:
|
|
theme.apply(self.w)
|
|
|
|
def onenter(self, event=None):
|
|
if config.getint('theme') > 1:
|
|
self.w.attributes("-transparentcolor", '')
|
|
self.blank_menubar.grid_remove()
|
|
self.theme_menubar.grid(row=0, columnspan=2, sticky=tk.NSEW)
|
|
|
|
def onleave(self, event=None):
|
|
if config.getint('theme') > 1 and event.widget == self.w:
|
|
self.w.attributes("-transparentcolor", 'grey4')
|
|
self.theme_menubar.grid_remove()
|
|
self.blank_menubar.grid(row=0, columnspan=2, sticky=tk.NSEW)
|
|
|
|
|
|
def test_logging():
|
|
logger.debug('Test from EDMarketConnector.py top-level test_logging()')
|
|
|
|
|
|
def log_locale(prefix: str) -> None:
|
|
logger.debug(f'''Locale: {prefix}
|
|
Locale LC_COLLATE: {locale.getlocale(locale.LC_COLLATE)}
|
|
Locale LC_CTYPE: {locale.getlocale(locale.LC_CTYPE)}
|
|
Locale LC_MONETARY: {locale.getlocale(locale.LC_MONETARY)}
|
|
Locale LC_NUMERIC: {locale.getlocale(locale.LC_NUMERIC)}
|
|
Locale LC_TIME: {locale.getlocale(locale.LC_TIME)}'''
|
|
)
|
|
|
|
|
|
# Run the app
|
|
if __name__ == "__main__": # noqa C901
|
|
logger.info(f'Startup v{appversion} : Running on Python v{sys.version}')
|
|
logger.debug(f'''Platform: {sys.platform} {sys.platform == "win32" and sys.getwindowsversion()}
|
|
argv[0]: {sys.argv[0]}
|
|
exec_prefix: {sys.exec_prefix}
|
|
executable: {sys.executable}
|
|
sys.path: {sys.path}'''
|
|
)
|
|
|
|
# We prefer a UTF-8 encoding gets set, but older Windows versions have
|
|
# issues with this. From Windows 10 1903 onwards we can rely on the
|
|
# manifest ActiveCodePage to set this, but that is silently ignored on
|
|
# all previous Windows versions.
|
|
# Trying to set a UTF-8 encoding on those older versions will fail with
|
|
# locale.Error: unsupported locale setting
|
|
# but we do need to make the attempt for when we're running from source.
|
|
log_locale('Initial Locale')
|
|
|
|
try:
|
|
locale.setlocale(locale.LC_ALL, '')
|
|
|
|
except locale.Error as e:
|
|
logger.error("Could not set LC_ALL to ''", exc_info=e)
|
|
|
|
else:
|
|
log_locale('After LC_ALL defaults set')
|
|
|
|
locale_startup = locale.getlocale(locale.LC_CTYPE)
|
|
logger.debug(f'Locale LC_CTYPE: {locale_startup}')
|
|
|
|
# Older Windows Versions and builds have issues with UTF-8, so only
|
|
# even attempt this where we think it will be safe.
|
|
|
|
if sys.platform == 'win32':
|
|
windows_ver = sys.getwindowsversion()
|
|
|
|
# <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)')
|
|
|
|
# TODO: unittests in place of these
|
|
# logger.debug('Test from __main__')
|
|
# test_logging()
|
|
|
|
class A(object):
|
|
|
|
class B(object):
|
|
|
|
def __init__(self):
|
|
logger.debug('A call from A.B.__init__')
|
|
self.__test()
|
|
_ = self.test_prop
|
|
|
|
def __test(self):
|
|
logger.debug("A call from A.B.__test")
|
|
|
|
@property
|
|
def test_prop(self):
|
|
logger.debug("test log from property")
|
|
return "Test property is testy"
|
|
|
|
# abinit = A.B()
|
|
|
|
# Plain, not via `logger`
|
|
print(f'{applongname} {appversion}')
|
|
|
|
Translations.install(config.get('language') or None) # Can generate errors so wait til log set up
|
|
|
|
root = tk.Tk(className=appname.lower())
|
|
|
|
# UI Scaling
|
|
"""
|
|
We scale the UI relative to what we find tk-scaling is on startup.
|
|
"""
|
|
ui_scale = config.getint('ui_scale')
|
|
# NB: This *also* catches a literal 0 value to re-set to the default 100
|
|
if not ui_scale:
|
|
ui_scale = 100
|
|
config.set('ui_scale', ui_scale)
|
|
theme.default_ui_scale = root.tk.call('tk', 'scaling')
|
|
logger.trace(f'Default tk scaling = {theme.default_ui_scale}')
|
|
theme.startup_ui_scale = ui_scale
|
|
root.tk.call('tk', 'scaling', theme.default_ui_scale * float(ui_scale) / 100.0)
|
|
app = AppWindow(root)
|
|
|
|
def messagebox_not_py3():
|
|
plugins_not_py3_last = config.getint('plugins_not_py3_last') or 0
|
|
if (plugins_not_py3_last + 86400) < int(time()) and len(plug.PLUGINS_not_py3):
|
|
# Yes, this is horribly hacky so as to be sure we match the key
|
|
# that we told Translators to use.
|
|
popup_text = "One or more of your enabled plugins do not yet have support for Python 3.x. Please see the " \
|
|
"list on the '{PLUGINS}' tab of '{FILE}' > '{SETTINGS}'. You should check if there is an " \
|
|
"updated version available, else alert the developer that they need to update the code for " \
|
|
"Python 3.x.\r\n\r\nYou can disable a plugin by renaming its folder to have '{DISABLED}' on " \
|
|
"the end of the name."
|
|
popup_text = popup_text.replace('\n', '\\n')
|
|
popup_text = popup_text.replace('\r', '\\r')
|
|
# Now the string should match, so try translation
|
|
popup_text = _(popup_text)
|
|
# And substitute in the other words.
|
|
popup_text = popup_text.format(PLUGINS=_('Plugins'), FILE=_('File'), SETTINGS=_('Settings'),
|
|
DISABLED='.disabled')
|
|
# And now we do need these to be actual \r\n
|
|
popup_text = popup_text.replace('\\n', '\n')
|
|
popup_text = popup_text.replace('\\r', '\r')
|
|
|
|
tk.messagebox.showinfo(
|
|
_('EDMC: Plugins Without Python 3.x Support'),
|
|
popup_text
|
|
)
|
|
config.set('plugins_not_py3_last', int(time()))
|
|
|
|
root.after(0, messagebox_not_py3)
|
|
root.mainloop()
|
|
|
|
logger.info('Exiting')
|