-
Notifications
You must be signed in to change notification settings - Fork 155
/
EDMarketConnector.py
executable file
·2333 lines (1921 loc) · 102 KB
/
EDMarketConnector.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/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