diff --git a/EDMC.py b/EDMC.py index 46e34c0c..126e1b5a 100755 --- a/EDMC.py +++ b/EDMC.py @@ -82,7 +82,11 @@ try: config.set('querytime', getmtime(args.j)) else: session = companion.Session() - session.login(config.get('username'), config.get('password')) + if config.get('cmdrs'): + username = config.get('fdev_usernames')[0] + session.login(username, config.get_password(username)) + else: # <= 2.25 not yet migrated + session.login(config.get('username'), config.get('password')) querytime = int(time()) data = session.query() config.set('querytime', querytime) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index 7660c4b0..9479f2e6 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -6,6 +6,7 @@ from sys import platform from collections import OrderedDict from functools import partial import json +import keyring from os import chdir, mkdir from os.path import dirname, expanduser, isdir, join import re @@ -28,8 +29,9 @@ if __debug__: signal.signal(signal.SIGTERM, lambda sig, frame: pdb.Pdb().set_trace(frame)) from config import appname, applongname, config -if platform == 'win32' and getattr(sys, 'frozen', False): - chdir(dirname(sys.path[0])) +if getattr(sys, 'frozen', False): + if platform == 'win32': + chdir(dirname(sys.path[0])) # By default py2exe tries to write log to dirname(sys.executable) which fails when installed import tempfile sys.stdout = sys.stderr = open(join(tempfile.gettempdir(), '%s.log' % appname), 'wt', 0) # unbuffered @@ -239,8 +241,6 @@ class AppWindow: tk.Label(self.blank_menubar).grid() theme.register_alternate((self.menubar, self.theme_menubar, self.blank_menubar), {'row':0, 'columnspan':2, 'sticky':tk.NSEW}) - self.set_labels() - # update geometry if config.get('geometry'): match = re.match('\+([\-\d]+)\+([\-\d]+)', config.get('geometry')) @@ -281,17 +281,42 @@ class AppWindow: # Load updater after UI creation (for WinSparkle) import update self.updater = update.Updater(self.w) + if not getattr(sys, 'frozen', False): + self.updater.checkForUpdates() # Sparkle / WinSparkle does this automatically for packaged apps - # First run - if not config.get('username') or not config.get('password'): - prefs.PreferencesDialog(self.w, self.postprefs) - else: - self.login() + try: + config.get_password('') # Prod SecureStorage on Linux to initialise + except RuntimeError: + pass + + # Migration from <= 2.25 + if not config.get('cmdrs') and config.get('username') and config.get('password'): + try: + self.session.login(config.get('username'), config.get('password')) + data = self.session.query() + prefs.migrate(data['commander']['name']) + except: + if __debug__: print_exc() + + self.postprefs() # Companion login happens in callback from monitor + + if keyring.get_keyring().priority < 1: + self.status['text'] = 'Warning: Storing passwords as text' # Shouldn't happen unless no secure storage on Linux + + # Try to obtain exclusive lock on journal cache, even if we don't need it yet + if not eddn.load(): + self.status['text'] = 'Error: Is another copy of this app already running?' # Shouldn't happen - don't bother localizing # callback after the Preferences dialog is applied def postprefs(self): self.set_labels() # in case language has changed - self.login() # in case credentials gave changed + + # (Re-)install hotkey monitoring + hotkeymgr.register(self.w, config.getint('hotkey_code'), config.getint('hotkey_mods')) + + # (Re-)install log monitoring + if not monitor.start(self.w): + self.status['text'] = 'Error: Check %s' % _('E:D journal file location') # Location of the new Journal file in E:D 2.2 # set main window labels, e.g. after language change def set_labels(self): @@ -328,11 +353,16 @@ class AppWindow: self.edit_menu.entryconfigure(0, label=_('Copy')) # As in Copy and Paste def login(self): - self.status['text'] = _('Logging in...') + if not self.status['text']: + self.status['text'] = _('Logging in...') self.button['state'] = self.theme_button['state'] = tk.DISABLED self.w.update_idletasks() try: - self.session.login(config.get('username'), config.get('password')) + if not monitor.cmdr or not config.get('cmdrs') or monitor.cmdr not in config.get('cmdrs'): + raise companion.CredentialsError() + idx = config.get('cmdrs').index(monitor.cmdr) + username = config.get('fdev_usernames')[idx] + self.session.login(username, config.get_password(username)) self.status['text'] = '' except companion.VerificationRequired: return prefs.AuthenticationDialog(self.w, partial(self.verify, self.login)) @@ -341,21 +371,6 @@ class AppWindow: except Exception as e: if __debug__: print_exc() self.status['text'] = unicode(e) - - if not getattr(sys, 'frozen', False): - self.updater.checkForUpdates() # Sparkle / WinSparkle does this automatically for packaged apps - - # Try to obtain exclusive lock on journal cache, even if we don't need it yet - if not eddn.load(): - self.status['text'] = 'Error: Is another copy of this app already running?' # Shouldn't happen - don't bother localizing - - # (Re-)install hotkey monitoring - hotkeymgr.register(self.w, config.getint('hotkey_code'), config.getint('hotkey_mods')) - - # (Re-)install log monitoring - if not monitor.start(self.w): - self.status['text'] = 'Error: Check %s' % _('E:D journal file location') # Location of the new Journal file in E:D 2.2 - self.cooldown() # callback after verification code @@ -375,7 +390,7 @@ class AppWindow: auto_update = not event play_sound = (auto_update or int(event.type) == self.EVENT_VIRTUAL) and not config.getint('hotkey_mute') - if (monitor.cmdr and not monitor.mode) or monitor.is_beta: + if not monitor.cmdr or not monitor.mode or monitor.is_beta: return # In CQC or Beta - do nothing if not retrying: @@ -485,7 +500,7 @@ class AppWindow: self.status['text'] = '' # Update credits and ship info and send to EDSM - if config.getint('output') & config.OUT_SYS_EDSM and not monitor.is_beta: + if config.getint('output') & config.OUT_SYS_EDSM: try: if data['commander'].get('credits') is not None: monitor.credits = (data['commander']['credits'], data['commander'].get('debt', 0)) @@ -576,12 +591,12 @@ class AppWindow: self.w.update_idletasks() # Send interesting events to EDSM - if config.getint('output') & config.OUT_SYS_EDSM and not monitor.is_beta: + if config.getint('output') & config.OUT_SYS_EDSM and not monitor.is_beta and config.get('cmdrs') and monitor.cmdr in config.get('cmdrs') and config.get('edsm_usernames')[config.get('cmdrs').index(monitor.cmdr)]: self.status['text'] = _('Sending data to EDSM...') self.w.update_idletasks() try: # Update system status on startup - if monitor.mode and not entry['event']: + if monitor.mode and monitor.system and not entry['event']: self.edsm.lookup(monitor.system) # Send credits to EDSM on new game (but not on startup - data might be old) @@ -616,6 +631,18 @@ class AppWindow: self.status['text'] = '' self.edsmpoll() + # Companion login - do this after EDSM so any EDSM errors don't mask login errors + if entry['event'] in [None, 'NewCommander', 'LoadGame'] and monitor.cmdr and not monitor.is_beta: + if config.get('cmdrs') and monitor.cmdr in config.get('cmdrs'): + prefs.make_current(monitor.cmdr) + self.login() + elif config.get('cmdrs') and entry['event'] == 'NewCommander': + cmdrs = config.get('cmdrs') + cmdrs[0] = monitor.cmdr # New Cmdr uses same credentials as old + config.set('cmdrs', cmdrs) + else: + prefs.PreferencesDialog(self.w, self.postprefs) # First run or failed migration + if not entry['event'] or not monitor.mode: return # Startup or in CQC @@ -695,7 +722,7 @@ class AppWindow: def shipyard_url(self, shipname=None): - if (monitor.cmdr and not monitor.mode) or monitor.is_beta: + if not monitor.cmdr or not monitor.mode or monitor.is_beta: return False # In CQC or Beta - do nothing self.status['text'] = _('Fetching data...') @@ -839,14 +866,40 @@ class AppWindow: # Run the app if __name__ == "__main__": - # Ensure only one copy of the app is running. OSX does this automatically. Linux TODO. + # Ensure only one copy of the app is running under this user account. OSX does this automatically. Linux TODO. if platform == 'win32': import ctypes - h = ctypes.windll.user32.FindWindowW(u'TkTopLevel', unicode(applongname)) - if h: - ctypes.windll.user32.ShowWindow(h, 9) # SW_RESTORE - ctypes.windll.user32.SetForegroundWindow(h) # Probably not necessary - sys.exit(0) + from ctypes.wintypes import * + EnumWindows = ctypes.windll.user32.EnumWindows + EnumWindowsProc = ctypes.WINFUNCTYPE(BOOL, HWND, LPARAM) + GetClassName = ctypes.windll.user32.GetClassNameW + GetClassName.argtypes = [HWND, LPWSTR, ctypes.c_int] + GetWindowText = ctypes.windll.user32.GetWindowTextW + GetWindowText.argtypes = [HWND, LPWSTR, ctypes.c_int] + GetWindowTextLength = ctypes.windll.user32.GetWindowTextLengthW + GetProcessHandleFromHwnd = ctypes.windll.oleacc.GetProcessHandleFromHwnd + SetForegroundWindow = ctypes.windll.user32.SetForegroundWindow + ShowWindow = ctypes.windll.user32.ShowWindow + + def WindowTitle(h): + if h: + l = GetWindowTextLength(h) + 1 + buf = ctypes.create_unicode_buffer(l) + if GetWindowText(h, buf, l): + return buf.value + return None + + def enumwindowsproc(hWnd, lParam): + # class name limited to 256 - https://msdn.microsoft.com/en-us/library/windows/desktop/ms633576 + cls = ctypes.create_unicode_buffer(257) + if GetClassName(hWnd, cls, 257) and cls.value == 'TkTopLevel' and WindowTitle(hWnd) == applongname and GetProcessHandleFromHwnd(hWnd): + # If GetProcessHandleFromHwnd succeeds then the app is already running as this user + ShowWindow(hWnd, 9) # SW_RESTORE + SetForegroundWindow(hWnd) + sys.exit(0) + return True + + EnumWindows(EnumWindowsProc(enumwindowsproc), 0) root = tk.Tk() app = AppWindow(root) diff --git a/README.md b/README.md index ccfce593..70e55fa1 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,14 @@ If 2 this problem may or may not resolve itself in time. This problem is tracked as [Issue #165](https://github.com/Marginal/EDMarketConnector/issues/165). +### Credentials settings are greyed out +You can't edit your Username/Password or EDSM Commander Name/API Key while Elite: Dangerous is at the Main Menu or in Beta. You will be able to edit these values once you've entered the (non-Beta) game. + +### I run two instances of E:D simultaneously, but I can't run two instances of EDMC +EDMC supports this scenario if you run the second instance of E:D in a *different* user account - e.g. using `runas` on Windows. Run the second instance of EDMC in the same user account as the second instance of E:D. + +EDMC doesn't support running two instances of E:D in the *same* user account. EDMC will only respond to the instance of E:D that you ran last. + ### Error: Can't connect to EDDN EDMC needs to talk to eddn-gateway.elite-markets.net on port 8080. If you consistently receive this error check that your router or VPN configuration allows port 8080 / tcp outbound. @@ -158,17 +166,17 @@ Download and extract the source code of the [latest release](https://github.com/ Mac: -* Requires the Python “requests” and “watchdog” modules, plus an up-to-date “py2app” module if you also want to package the app - install these with `easy_install -U requests watchdog py2app` . +* Requires the Python “keyring”, “requests” and “watchdog” modules, plus an up-to-date “py2app” module if you also want to package the app - install these with `easy_install -U keyring requests watchdog py2app` . * Run with `./EDMarketConnector.py` . Windows: -* Requires Python2.7 and the Python “requests” and “watchdog” modules. +* Requires Python2.7 and the Python “keyring”, “requests” and “watchdog” modules, plus “py2exe” 0.6 if you also want to package the app. * Run with `EDMarketConnector.py` . Linux: -* Requires the Python “imaging-tk”, “iniparse” and “requests” modules. On Debian-based systems install these with `sudo apt-get install python-imaging-tk python-iniparse python-requests` . +* Requires the Python “imaging-tk”, “iniparse”, “keyring” and “requests” modules. On Debian-based systems install these with `sudo apt-get install python-imaging-tk python-iniparse python-keyring python-requests` . * Run with `./EDMarketConnector.py` . Command-line @@ -243,6 +251,7 @@ Acknowledgements * Thanks to Taras Velychko for the Ukranian translation. * Thanks to [James Muscat](https://github.com/jamesremuscat) for [EDDN](https://github.com/jamesremuscat/EDDN/wiki) and to [Cmdr Anthor](https://github.com/AnthorNet) for the [stats](http://eddn-gateway.elite-markets.net/). * Thanks to [Andargor](https://github.com/Andargor) for the idea of using the “Companion” interface in [edce-client](https://github.com/Andargor/edce-client). +* Uses [Python Keyring Lib](https://github.com/jaraco/keyring) by Jason R. Coombs, Kang Zhang, et al. * Uses [Sparkle](https://github.com/sparkle-project/Sparkle) by [Andy Matuschak](http://andymatuschak.org/) and the [Sparkle Project](https://github.com/sparkle-project). * Uses [WinSparkle](https://github.com/vslavik/winsparkle/wiki) by [Václav Slavík](https://github.com/vslavik). * Uses [OneSky](http://www.oneskyapp.com/) for [translation management](https://marginal.oneskyapp.com/collaboration/project?id=52710). diff --git a/companion.py b/companion.py index 11b2f89d..0a4cd183 100644 --- a/companion.py +++ b/companion.py @@ -1,9 +1,10 @@ import requests from collections import defaultdict from cookielib import LWPCookieJar +import hashlib import numbers import os -from os.path import dirname, join +from os.path import dirname, isfile, join import sys from sys import platform import time @@ -178,6 +179,7 @@ class Session: def __init__(self): self.state = Session.STATE_INIT self.credentials = None + self.session = None # yuck suppress InsecurePlatformWarning under Python < 2.7.9 which lacks SNI support try: @@ -189,14 +191,6 @@ class Session: if platform=='win32' and getattr(sys, 'frozen', False): os.environ['REQUESTS_CA_BUNDLE'] = join(dirname(sys.executable), 'cacert.pem') - self.session = requests.Session() - self.session.headers['User-Agent'] = 'Mozilla/5.0 (iPhone; CPU iPhone OS 7_1_2 like Mac OS X) AppleWebKit/537.51.2 (KHTML, like Gecko) Mobile/11D257' - self.session.cookies = LWPCookieJar(join(config.app_dir, 'cookies.txt')) - try: - self.session.cookies.load() - except IOError: - pass - def login(self, username=None, password=None): if (not username or not password): if not self.credentials: @@ -208,8 +202,20 @@ class Session: if self.credentials == credentials and self.state == Session.STATE_OK: return # already logged in - if self.credentials and self.credentials['email'] != credentials['email']: # changed account - self.session.cookies.clear() + + if not self.credentials or self.credentials['email'] != credentials['email']: # changed account + self.close() + self.session = requests.Session() + self.session.headers['User-Agent'] = 'Mozilla/5.0 (iPhone; CPU iPhone OS 7_1_2 like Mac OS X) AppleWebKit/537.51.2 (KHTML, like Gecko) Mobile/11D257' + cookiefile = join(config.app_dir, 'cookies-%s.txt' % hashlib.md5(credentials['email']).hexdigest()) + if not isfile(cookiefile) and isfile(join(config.app_dir, 'cookies.txt')): + os.rename(join(config.app_dir, 'cookies.txt'), cookiefile) # migration from <= 2.25 + self.session.cookies = LWPCookieJar(cookiefile) + try: + self.session.cookies.load() + except IOError: + pass + self.credentials = credentials self.state = Session.STATE_INIT try: @@ -237,7 +243,7 @@ class Session: r = self.session.post(URL_CONFIRM, data = {'code' : code}, timeout=timeout) if r.status_code != requests.codes.ok or r.url == URL_CONFIRM: # would have redirected away if success raise VerificationRequired() - self.save() # Save cookies now for use by command-line app + self.session.cookies.save() # Save cookies now for use by command-line app self.login() def query(self): @@ -270,16 +276,14 @@ class Session: return data - def save(self): - self.session.cookies.save() - def close(self): self.state = Session.STATE_NONE - try: - self.session.cookies.save() - self.session.close() - except: - pass + if self.session: + try: + self.session.cookies.save() + self.session.close() + except: + if __debug__: print_exc() self.session = None def dump(self, r): diff --git a/config.py b/config.py index 19a7bc9b..5481af72 100644 --- a/config.py +++ b/config.py @@ -1,3 +1,4 @@ +import keyring import numbers import sys from os import getenv, makedirs, mkdir, pardir @@ -8,7 +9,7 @@ from sys import platform appname = 'EDMarketConnector' applongname = 'E:D Market Connector' appcmdname = 'EDMC' -appversion = '2.2.5.0' +appversion = '2.2.6.1' update_feed = 'https://marginal.org.uk/edmarketconnector.xml' update_interval = 47*60*60 @@ -130,12 +131,12 @@ class Config: if not getattr(sys, 'frozen', False): # Don't use Python's settings if interactive - self.bundle = 'uk.org.marginal.%s' % appname.lower() - NSBundle.mainBundle().infoDictionary()['CFBundleIdentifier'] = self.bundle + self.identifier = 'uk.org.marginal.%s' % appname.lower() + NSBundle.mainBundle().infoDictionary()['CFBundleIdentifier'] = self.identifier else: - self.bundle = NSBundle.mainBundle().bundleIdentifier() + self.identifier = NSBundle.mainBundle().bundleIdentifier() self.defaults = NSUserDefaults.standardUserDefaults() - self.settings = dict(self.defaults.persistentDomainForName_(self.bundle) or {}) # make writeable + self.settings = dict(self.defaults.persistentDomainForName_(self.identifier) or {}) # make writeable # Check out_dir exists if not self.get('outdir') or not isdir(self.get('outdir')): @@ -161,7 +162,7 @@ class Config: self.settings.pop(key, None) def save(self): - self.defaults.setPersistentDomain_forName_(self.settings, self.bundle) + self.defaults.setPersistentDomain_forName_(self.settings, self.identifier) self.defaults.synchronize() def close(self): @@ -188,6 +189,8 @@ class Config: self.respath = dirname(getattr(sys, 'frozen', False) and sys.executable or __file__) + self.identifier = applongname + self.hkey = HKEY() disposition = DWORD() if RegCreateKeyEx(HKEY_CURRENT_USER, r'Software\Marginal\EDMarketConnector', 0, None, 0, KEY_ALL_ACCESS, None, ctypes.byref(self.hkey), ctypes.byref(disposition)): @@ -281,6 +284,8 @@ class Config: self.respath = dirname(__file__) + self.identifier = 'uk.org.marginal.%s' % appname.lower() + self.filename = join(getenv('XDG_CONFIG_HOME', expanduser('~/.config')), appname, '%s.ini' % appname) if not isdir(dirname(self.filename)): makedirs(dirname(self.filename)) @@ -298,7 +303,7 @@ class Config: try: val = self.config.get(self.SECTION, key) if u'\n' in val: - return val.split(u'\n') + return val.split(u'\n')[:-1] else: return val except: @@ -314,7 +319,7 @@ class Config: if isinstance(val, basestring) or isinstance(val, numbers.Integral): self.config.set(self.SECTION, key, val) elif hasattr(val, '__iter__'): # iterable - self.config.set(self.SECTION, key, u'\n'.join([unicode(x) for x in val])) + self.config.set(self.SECTION, key, u'\n'.join([unicode(x) for x in val] + [u';'])) else: raise NotImplementedError() @@ -334,5 +339,19 @@ class Config: def __init__(self): raise NotImplementedError('Implement me') + # Common + + def get_password(self, account): + return keyring.get_password(self.identifier, account) + + def set_password(self, account, password): + keyring.set_password(self.identifier, account, password) + + def delete_password(self, account): + try: + keyring.delete_password(self.identifier, account) + except: + pass # don't care - silently fail + # singleton config = Config() diff --git a/edsm.py b/edsm.py index 3a881fe2..79f6a1f8 100644 --- a/edsm.py +++ b/edsm.py @@ -7,6 +7,7 @@ import urllib2 import Tkinter as tk from config import appname, applongname, appversion, config +from monitor import monitor if __debug__: from traceback import print_exc @@ -79,10 +80,11 @@ class EDSM: # Call an EDSM endpoint with args (which should be quoted) def call(self, endpoint, args, check_msgnum=True): try: + idx = config.get('cmdrs').index(monitor.cmdr) url = 'https://www.edsm.net/%s?commanderName=%s&apiKey=%s&fromSoftware=%s&fromSoftwareVersion=%s' % ( endpoint, - urllib2.quote(config.get('edsm_cmdrname').encode('utf-8')), - urllib2.quote(config.get('edsm_apikey')), + urllib2.quote(config.get('edsm_usernames')[idx].encode('utf-8')), + urllib2.quote(config.get('edsm_apikeys')[idx]), urllib2.quote(applongname), urllib2.quote(appversion), ) + args diff --git a/monitor.py b/monitor.py index 41f0b305..53a96944 100644 --- a/monitor.py +++ b/monitor.py @@ -106,7 +106,7 @@ class EDLogs(FileSystemEventHandler): # Latest pre-existing logfile - e.g. if E:D is already running. Assumes logs sort alphabetically. # Do this before setting up the observer in case the journal directory has gone away try: - logfiles = sorted([x for x in listdir(self.currentdir) if x.startswith('Journal.')]) + logfiles = sorted([x for x in listdir(self.currentdir) if x.startswith('Journal.') and x.endswith('.log')]) self.logfile = logfiles and join(self.currentdir, logfiles[-1]) or None except: self.logfile = None @@ -129,6 +129,8 @@ class EDLogs(FileSystemEventHandler): print '%s "%s"' % (polling and 'Polling' or 'Monitoring', self.currentdir) print 'Start logfile "%s"' % self.logfile + self.event_queue.append(None) # Generate null event to signal (re)start + if not self.running(): self.thread = threading.Thread(target = self.worker, name = 'Journal worker') self.thread.daemon = True @@ -163,7 +165,7 @@ class EDLogs(FileSystemEventHandler): def on_created(self, event): # watchdog callback, e.g. client (re)started. - if not event.is_directory and basename(event.src_path).startswith('Journal.'): + if not event.is_directory and basename(event.src_path).startswith('Journal.') and basename(event.src_path).endswith('.log'): self.logfile = event.src_path def worker(self): @@ -181,8 +183,6 @@ class EDLogs(FileSystemEventHandler): except: if __debug__: print 'Invalid journal entry "%s"' % repr(line) - self.event_queue.append(None) # Generate null event to signal start - self.root.event_generate('<>', when="tail") else: loghandle = None @@ -197,7 +197,7 @@ class EDLogs(FileSystemEventHandler): else: # Poll try: - logfiles = sorted([x for x in listdir(self.currentdir) if x.startswith('Journal.')]) + logfiles = sorted([x for x in listdir(self.currentdir) if x.startswith('Journal.') and x.endswith('.log')]) newlogfile = logfiles and join(self.currentdir, logfiles[-1]) or None except: if __debug__: print_exc() diff --git a/prefs.py b/prefs.py index bf7b0609..62208469 100644 --- a/prefs.py +++ b/prefs.py @@ -80,12 +80,13 @@ class PreferencesDialog(tk.Toplevel): parent.call('tk::unsupported::MacWindowStyle', 'style', self, 'utility') self.resizable(tk.FALSE, tk.FALSE) - style = ttk.Style() + self.cmdr = False # Note if Cmdr changes in the Journal frame = ttk.Frame(self) frame.grid(sticky=tk.NSEW) notebook = nb.Notebook(frame) + notebook.bind('<>', self.outvarchanged) # Recompute on tab change PADX = 10 BUTTONX = 12 # indent Checkbuttons and Radiobuttons @@ -96,17 +97,23 @@ class PreferencesDialog(tk.Toplevel): nb.Label(credframe, text=_('Credentials')).grid(padx=PADX, sticky=tk.W) # Section heading in settings ttk.Separator(credframe, orient=tk.HORIZONTAL).grid(columnspan=2, padx=PADX, pady=PADY, sticky=tk.EW) - nb.Label(credframe, text=_('Please log in with your Elite: Dangerous account details')).grid(padx=PADX, columnspan=2, sticky=tk.W) # Use same text as E:D Launcher's login dialog - nb.Label(credframe, text=_('Username (Email)')).grid(row=10, padx=PADX, sticky=tk.W) # Use same text as E:D Launcher's login dialog - nb.Label(credframe, text=_('Password')).grid(row=11, padx=PADX, sticky=tk.W) # Use same text as E:D Launcher's login dialog + self.cred_label = nb.Label(credframe, text=_('Please log in with your Elite: Dangerous account details')) # Use same text as E:D Launcher's login dialog + self.cred_label.grid(padx=PADX, columnspan=2, sticky=tk.W) + self.cmdr_label = nb.Label(credframe, text=_('Cmdr')) # Main window + self.cmdr_label.grid(row=10, padx=PADX, sticky=tk.W) + self.username_label = nb.Label(credframe, text=_('Username (Email)')) # Use same text as E:D Launcher's login dialog + self.username_label.grid(row=11, padx=PADX, sticky=tk.W) + self.password_label = nb.Label(credframe, text=_('Password')) # Use same text as E:D Launcher's login dialog + self.password_label.grid(row=12, padx=PADX, sticky=tk.W) + self.cmdr_text = nb.Label(credframe) + self.cmdr_text.grid(row=10, column=1, padx=PADX, pady=PADY, sticky=tk.W) self.username = nb.Entry(credframe) - self.username.insert(0, config.get('username') or '') - self.username.grid(row=10, column=1, padx=PADX, pady=PADY, sticky=tk.EW) - self.username.focus_set() + self.username.grid(row=11, column=1, padx=PADX, pady=PADY, sticky=tk.EW) + if not monitor.is_beta and monitor.cmdr: + self.username.focus_set() self.password = nb.Entry(credframe, show=u'•') - self.password.insert(0, config.get('password') or '') - self.password.grid(row=11, column=1, padx=PADX, pady=PADY, sticky=tk.EW) + self.password.grid(row=12, column=1, padx=PADX, pady=PADY, sticky=tk.EW) nb.Label(credframe).grid(sticky=tk.W) # big spacer nb.Label(credframe, text=_('Privacy')).grid(padx=PADX, sticky=tk.W) # Section heading in settings @@ -125,13 +132,17 @@ class PreferencesDialog(tk.Toplevel): output = config.getint('output') or (config.OUT_MKT_EDDN | config.OUT_SYS_EDDN | config.OUT_SHIP) # default settings - nb.Label(outframe, text=_('Please choose what data to save')).grid(columnspan=2, padx=PADX, sticky=tk.W) + self.out_label = nb.Label(outframe, text=_('Please choose what data to save')) + self.out_label.grid(columnspan=2, padx=PADX, sticky=tk.W) self.out_csv = tk.IntVar(value = (output & config.OUT_MKT_CSV ) and 1) - nb.Checkbutton(outframe, text=_('Market data in CSV format file'), variable=self.out_csv, command=self.outvarchanged).grid(columnspan=2, padx=BUTTONX, sticky=tk.W) + self.out_csv_button = nb.Checkbutton(outframe, text=_('Market data in CSV format file'), variable=self.out_csv, command=self.outvarchanged) + self.out_csv_button.grid(columnspan=2, padx=BUTTONX, sticky=tk.W) self.out_td = tk.IntVar(value = (output & config.OUT_MKT_TD ) and 1) - nb.Checkbutton(outframe, text=_('Market data in Trade Dangerous format file'), variable=self.out_td, command=self.outvarchanged).grid(columnspan=2, padx=BUTTONX, sticky=tk.W) + self.out_td_button = nb.Checkbutton(outframe, text=_('Market data in Trade Dangerous format file'), variable=self.out_td, command=self.outvarchanged) + self.out_td_button.grid(columnspan=2, padx=BUTTONX, sticky=tk.W) self.out_ship= tk.IntVar(value = (output & (config.OUT_SHIP|config.OUT_SHIP_EDS|config.OUT_SHIP_CORIOLIS) and 1)) - nb.Checkbutton(outframe, text=_('Ship loadout'), variable=self.out_ship, command=self.outvarchanged).grid(columnspan=2, padx=BUTTONX, pady=(5,0), sticky=tk.W) # Output setting + self.out_ship_button = nb.Checkbutton(outframe, text=_('Ship loadout'), variable=self.out_ship, command=self.outvarchanged) # Output setting + self.out_ship_button.grid(columnspan=2, padx=BUTTONX, pady=(5,0), sticky=tk.W) self.out_auto = tk.IntVar(value = 0 if output & config.OUT_MKT_MANUAL else 1) # inverted self.out_auto_button = nb.Checkbutton(outframe, text=_('Automatically update on docking'), variable=self.out_auto, command=self.outvarchanged) # Output setting self.out_auto_button.grid(columnspan=2, padx=BUTTONX, pady=(5,0), sticky=tk.W) @@ -155,7 +166,8 @@ class PreferencesDialog(tk.Toplevel): HyperlinkLabel(eddnframe, text='Elite Dangerous Data Network', background=nb.Label().cget('background'), url='https://github.com/jamesremuscat/EDDN/wiki', underline=True).grid(padx=PADX, sticky=tk.W) # Don't translate self.eddn_station= tk.IntVar(value = (output & config.OUT_MKT_EDDN) and 1) - nb.Checkbutton(eddnframe, text=_('Send station data to the Elite Dangerous Data Network'), variable=self.eddn_station, command=self.outvarchanged).grid(padx=BUTTONX, pady=(5,0), sticky=tk.W) # Output setting + self.eddn_station_button = nb.Checkbutton(eddnframe, text=_('Send station data to the Elite Dangerous Data Network'), variable=self.eddn_station, command=self.outvarchanged) # Output setting + self.eddn_station_button.grid(padx=BUTTONX, pady=(5,0), sticky=tk.W) self.eddn_auto_button = nb.Checkbutton(eddnframe, text=_('Automatically update on docking'), variable=self.out_auto, command=self.outvarchanged) # Output setting self.eddn_auto_button.grid(padx=BUTTONX, sticky=tk.W) self.eddn_system = tk.IntVar(value = (output & config.OUT_SYS_EDDN) and 1) @@ -180,17 +192,20 @@ class PreferencesDialog(tk.Toplevel): self.edsm_label = HyperlinkLabel(edsmframe, text=_('Elite Dangerous Star Map credentials'), background=nb.Label().cget('background'), url='https://www.edsm.net/settings/api', underline=True) # Section heading in settings self.edsm_label.grid(columnspan=2, padx=PADX, sticky=tk.W) - self.edsm_cmdr_label = nb.Label(edsmframe, text=_('Commander Name')) # EDSM setting + self.edsm_cmdr_label = nb.Label(edsmframe, text=_('Cmdr')) # Main window self.edsm_cmdr_label.grid(row=10, padx=PADX, sticky=tk.W) - self.edsm_cmdr = nb.Entry(edsmframe) - self.edsm_cmdr.insert(0, config.get('edsm_cmdrname') or '') - self.edsm_cmdr.grid(row=10, column=1, padx=PADX, pady=PADY, sticky=tk.EW) + self.edsm_cmdr_text = nb.Label(edsmframe) + self.edsm_cmdr_text.grid(row=10, column=1, padx=PADX, pady=PADY, sticky=tk.W) + + self.edsm_user_label = nb.Label(edsmframe, text=_('Commander Name')) # EDSM setting + self.edsm_user_label.grid(row=11, padx=PADX, sticky=tk.W) + self.edsm_user = nb.Entry(edsmframe) + self.edsm_user.grid(row=11, column=1, padx=PADX, pady=PADY, sticky=tk.EW) self.edsm_apikey_label = nb.Label(edsmframe, text=_('API Key')) # EDSM setting - self.edsm_apikey_label.grid(row=11, padx=PADX, sticky=tk.W) + self.edsm_apikey_label.grid(row=12, padx=PADX, sticky=tk.W) self.edsm_apikey = nb.Entry(edsmframe) - self.edsm_apikey.insert(0, config.get('edsm_apikey') or '') - self.edsm_apikey.grid(row=11, column=1, padx=PADX, pady=PADY, sticky=tk.EW) + self.edsm_apikey.grid(row=12, column=1, padx=PADX, pady=PADY, sticky=tk.EW) notebook.add(edsmframe, text='EDSM') # Not translated @@ -323,30 +338,59 @@ class PreferencesDialog(tk.Toplevel): 0x10000, None, position): self.geometry("+%d+%d" % (position.left, position.top)) - def outvarchanged(self): + def outvarchanged(self, event=None): + self.cmdr_text['state'] = self.edsm_cmdr_text['state'] = tk.NORMAL # must be writable to update + self.cmdr_text['text'] = self.edsm_cmdr_text['text'] = (monitor.cmdr or _('None')) + (monitor.is_beta and ' [Beta]' or '') # No hotkey/shortcut currently defined + if self.cmdr != monitor.cmdr: + # Cmdr has changed - update settings + self.username['state'] = tk.NORMAL + self.username.delete(0, tk.END) + self.password['state'] = tk.NORMAL + self.password.delete(0, tk.END) + self.edsm_user['state'] = tk.NORMAL + self.edsm_user.delete(0, tk.END) + self.edsm_apikey['state'] = tk.NORMAL + self.edsm_apikey.delete(0, tk.END) + if monitor.cmdr and config.get('cmdrs') and monitor.cmdr in config.get('cmdrs'): + config_idx = config.get('cmdrs').index(monitor.cmdr) + self.username.insert(0, config.get('fdev_usernames')[config_idx] or '') + self.password.insert(0, config.get_password(config.get('fdev_usernames')[config_idx]) or '') + self.edsm_user.insert(0, config.get('edsm_usernames')[config_idx] or '') + self.edsm_apikey.insert(0, config.get('edsm_apikeys')[config_idx] or '') + elif monitor.cmdr and not config.get('cmdrs') and config.get('username') and config.get('password'): + # migration from <= 2.25 + self.username.insert(0, config.get('username') or '') + self.password.insert(0, config.get('password') or '') + self.edsm_user.insert(0,config.get('edsm_cmdrname') or '') + self.edsm_apikey.insert(0, config.get('edsm_apikey') or '') + self.cmdr = monitor.cmdr + + cmdr_state = not monitor.is_beta and monitor.cmdr and tk.NORMAL or tk.DISABLED + self.cred_label['state'] = self.cmdr_label['state'] = self.username_label['state'] = self.password_label['state'] = cmdr_state + self.cmdr_text['state'] = self.username['state'] = self.password['state'] = cmdr_state + self.displaypath(self.outdir, self.outdir_entry) self.displaypath(self.logdir, self.logdir_entry) logdir = self.logdir.get() logvalid = logdir and exists(logdir) - local = self.out_td.get() or self.out_csv.get() or self.out_ship.get() - self.out_auto_button['state'] = local and logvalid and not monitor.is_beta and tk.NORMAL or tk.DISABLED + self.out_label['state'] = self.out_csv_button['state'] = self.out_td_button['state'] = self.out_ship_button['state'] = not monitor.is_beta and tk.NORMAL or tk.DISABLED + local = not monitor.is_beta and (self.out_td.get() or self.out_csv.get() or self.out_ship.get()) + self.out_auto_button['state'] = local and logvalid and tk.NORMAL or tk.DISABLED self.outdir_label['state'] = local and tk.NORMAL or tk.DISABLED self.outbutton['state'] = local and tk.NORMAL or tk.DISABLED self.outdir_entry['state'] = local and 'readonly' or tk.DISABLED + self.eddn_station_button['state'] = not monitor.is_beta and tk.NORMAL or tk.DISABLED self.eddn_auto_button['state'] = self.eddn_station.get() and logvalid and not monitor.is_beta and tk.NORMAL or tk.DISABLED self.eddn_system_button['state']= logvalid and tk.NORMAL or tk.DISABLED self.eddn_delay_button['state'] = logvalid and eddn.replayfile and self.eddn_system.get() and tk.NORMAL or tk.DISABLED - self.edsm_log_button['state'] = logvalid and tk.NORMAL or tk.DISABLED - edsm_state = logvalid and self.edsm_log.get() and tk.NORMAL or tk.DISABLED - self.edsm_label['state'] = edsm_state - self.edsm_cmdr_label['state'] = edsm_state - self.edsm_apikey_label['state'] = edsm_state - self.edsm_cmdr['state'] = edsm_state - self.edsm_apikey['state'] = edsm_state + self.edsm_log_button['state'] = logvalid and not monitor.is_beta and tk.NORMAL or tk.DISABLED + edsm_state = logvalid and monitor.cmdr and not monitor.is_beta and self.edsm_log.get() and tk.NORMAL or tk.DISABLED + self.edsm_label['state'] = self.edsm_cmdr_label['state'] = self.edsm_user_label['state'] = self.edsm_apikey_label['state'] = edsm_state + self.edsm_cmdr_text['state'] = self.edsm_user['state'] = self.edsm_apikey['state'] = edsm_state def filebrowse(self, title, pathvar): if platform != 'win32': @@ -476,9 +520,22 @@ class PreferencesDialog(tk.Toplevel): def apply(self): - credentials = (config.get('username'), config.get('password')) - config.set('username', self.username.get().strip()) - config.set('password', self.password.get().strip()) + if self.cmdr and not monitor.is_beta: + if self.password.get().strip(): + config.set_password(self.username.get().strip(), self.password.get().strip()) # Can fail if keyring not unlocked + else: + config.delete_password(self.username.get().strip()) # user may have cleared the password field + if not config.get('cmdrs'): + config.set('cmdrs', [self.cmdr]) + config.set('fdev_usernames', [self.username.get().strip()]) + config.set('edsm_usernames', [self.edsm_user.get().strip()]) + config.set('edsm_apikeys', [self.edsm_apikey.get().strip()]) + else: + idx = config.get('cmdrs').index(self.cmdr) if self.cmdr in config.get('cmdrs') else -1 + _putfirst('cmdrs', idx, self.cmdr) + _putfirst('fdev_usernames', idx, self.username.get().strip()) + _putfirst('edsm_usernames', idx, self.edsm_user.get().strip()) + _putfirst('edsm_apikeys', idx, self.edsm_apikey.get().strip()) config.set('output', (self.out_td.get() and config.OUT_MKT_TD) + @@ -491,9 +548,6 @@ class PreferencesDialog(tk.Toplevel): (self.edsm_log.get() and config.OUT_SYS_EDSM)) config.set('outdir', self.outdir.get().startswith('~') and join(config.home, self.outdir.get()[2:]) or self.outdir.get()) - config.set('edsm_cmdrname', self.edsm_cmdr.get().strip()) - config.set('edsm_apikey', self.edsm_apikey.get().strip()) - logdir = self.logdir.get() if config.default_journal_dir and logdir.lower() == config.default_journal_dir.lower(): config.set('journaldir', '') # default location @@ -623,3 +677,34 @@ class AuthenticationDialog(tk.Toplevel): self.parent.wm_attributes('-topmost', config.getint('always_ontop') and 1 or 0) self.destroy() if self.callback: self.callback(None) + +# migration from <= 2.25. Assumes current Cmdr corresponds to the saved credentials +def migrate(current_cmdr): + if current_cmdr and not config.get('cmdrs') and config.get('username') and config.get('password'): + config.set_password(config.get('username'), config.get('password')) # Can fail on Linux + config.set('cmdrs', [current_cmdr]) + config.set('fdev_usernames', [config.get('username')]) + config.set('edsm_usernames', [config.get('edsm_cmdrname') or '']) + config.set('edsm_apikeys', [config.get('edsm_apikey') or '']) + # XXX to be done for release + # config.delete('username') + # config.delete('password') + # config.delete('edsm_cmdrname') + # config.delete('edsm_apikey') + +# Put current Cmdr first in the lists +def make_current(current_cmdr): + if current_cmdr and config.get('cmdrs') and current_cmdr in config.get('cmdrs'): + idx = config.get('cmdrs').index(current_cmdr) + _putfirst('cmdrs', idx) + _putfirst('fdev_usernames', idx) + _putfirst('edsm_usernames', idx) + _putfirst('edsm_apikeys', idx) + +def _putfirst(setting, config_idx, new_value=None): + assert config_idx>=0 or new_value is not None, (setting, config_idx, new_value) + values = config.get(setting) + values.insert(0, new_value if config_idx<0 else values.pop(config_idx)) + if new_value is not None: + values[0] = new_value + config.set(setting, values) diff --git a/requirements b/requirements index 1724d128..139ea2ca 100644 --- a/requirements +++ b/requirements @@ -1,4 +1,5 @@ argh>=0.26.2 +keyring>=3.3 pathtools>=0.1.2 PyYAML>=3.12 requests>=2.11.1 diff --git a/setup.py b/setup.py index 299826d6..a02c7417 100755 --- a/setup.py +++ b/setup.py @@ -70,7 +70,7 @@ if sys.platform=='darwin': OPTIONS = { 'py2app': {'dist_dir': dist_dir, 'optimize': 2, - 'packages': [ 'requests' ], + 'packages': [ 'requests', 'keyring.backends' ], 'frameworks': [ 'Sparkle.framework' ], 'excludes': [ 'PIL', 'simplejson' ], 'iconfile': '%s.icns' % APPNAME, @@ -100,7 +100,7 @@ elif sys.platform=='win32': OPTIONS = { 'py2exe': {'dist_dir': dist_dir, 'optimize': 2, - 'packages': [ 'requests' ], + 'packages': [ 'requests', 'keyring.backends' ], 'excludes': [ 'PIL', 'simplejson' ], } } diff --git a/stats.py b/stats.py index 998b8afc..1bf23bb4 100644 --- a/stats.py +++ b/stats.py @@ -11,6 +11,7 @@ import myNotebook as nb import companion from companion import ship_map +from monitor import monitor import prefs if platform=='win32': @@ -175,6 +176,9 @@ class StatsDialog(): self.showstats() def showstats(self): + if not monitor.cmdr or monitor.is_beta: + return # In Beta - do nothing + self.status['text'] = _('Fetching data...') self.parent.update_idletasks()