From a67eb982d84acd8627738d148ff6d8e7346293db Mon Sep 17 00:00:00 2001 From: Jonathan Harris <jonathan@marginal.org.uk> Date: Sat, 21 Jan 2017 21:40:15 +0000 Subject: [PATCH 01/13] Multi-account support Fixes #121 --- EDMC.py | 5 +- EDMarketConnector.py | 69 +++++++++++-------- README.md | 3 + companion.py | 44 ++++++------ edsm.py | 6 +- monitor.py | 6 +- prefs.py | 156 +++++++++++++++++++++++++++++++++---------- stats.py | 4 ++ 8 files changed, 201 insertions(+), 92 deletions(-) diff --git a/EDMC.py b/EDMC.py index 46e34c0c..d720c809 100755 --- a/EDMC.py +++ b/EDMC.py @@ -82,7 +82,10 @@ try: config.set('querytime', getmtime(args.j)) else: session = companion.Session() - session.login(config.get('username'), config.get('password')) + if config.get('cmdrs'): + session.login(config.get('fdev_usernames')[0], config.get('fdev_passwords')[0]) + 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 552a5e5b..c57ef31b 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -236,8 +236,6 @@ class AppWindow: theme.register(self.theme_close) theme.register_alternate((self.menubar, self.theme_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')) @@ -274,17 +272,25 @@ 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() + self.postprefs() # Companion login happens in callback from monitor + + # 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): @@ -321,11 +327,15 @@ 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 monitor.cmdr not in config.get('cmdrs'): + raise companion.CredentialsError() + idx = config.get('cmdrs').index(monitor.cmdr) + self.session.login(config.get('fdev_usernames')[idx], config.get('fdev_passwords')[idx]) self.status['text'] = '' except companion.VerificationRequired: return prefs.AuthenticationDialog(self.w, partial(self.verify, self.login)) @@ -334,21 +344,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 @@ -368,7 +363,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: @@ -478,7 +473,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)) @@ -568,13 +563,25 @@ class AppWindow: self.edsm.link(monitor.system) self.w.update_idletasks() + # New Cmdr? + if entry['event'] in [None, 'NewCommander', 'LoadGame'] and monitor.cmdr and not monitor.is_beta: + prefs.migrate(monitor.cmdr) # migration from <= 2.25 + if config.get('cmdrs') and monitor.cmdr in config.get('cmdrs'): + prefs.make_current(monitor.cmdr) + 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 new Cmdr + # Send interesting events to EDSM if config.getint('output') & config.OUT_SYS_EDSM and not monitor.is_beta: 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) @@ -609,6 +616,10 @@ 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, 'LoadGame'] and monitor.cmdr and not monitor.is_beta: + self.login() + if not entry['event'] or not monitor.mode: return # Startup or in CQC @@ -688,7 +699,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...') diff --git a/README.md b/README.md index 48766df8..4321e30e 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,9 @@ 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. You will be able to edit these values once you've entered the game. + ### 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. 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/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..b7206ba1 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 @@ -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('<<JournalEvent>>', when="tail") else: loghandle = None diff --git a/prefs.py b/prefs.py index 51db8c16..9510b21a 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 = None # Note if Cmdr changes in the Journal frame = ttk.Frame(self) frame.grid(sticky=tk.NSEW) notebook = nb.Notebook(frame) + notebook.bind('<<NotebookTabChanged>>', 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, text=_('None')) # No hotkey/shortcut currently defined + 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, text=_('None')) # No hotkey/shortcut currently defined + 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 @@ -321,30 +336,57 @@ 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 and monitor.is_beta and ('%s [Beta]' % monitor.cmdr) or monitor.cmdr or _('None') # No hotkey/shortcut currently defined + if self.cmdr != monitor.cmdr: + # Cmdr has changed - update settings + if not monitor.is_beta: + migrate(monitor.cmdr) # migration from <= 2.25 + if monitor.cmdr and config.get('cmdrs') and monitor.cmdr in config.get('cmdrs'): + config_idx = config.get('cmdrs').index(monitor.cmdr) + else: + config_idx = None + self.username['state'] = tk.NORMAL + self.username.delete(0, tk.END) + self.username.insert(0, config_idx is not None and config.get('fdev_usernames')[config_idx] or '') + self.password['state'] = tk.NORMAL + self.password.delete(0, tk.END) + self.password.insert(0, config_idx is not None and config.get('fdev_passwords')[config_idx] or '') + self.edsm_user['state'] = tk.NORMAL + self.edsm_user.delete(0, tk.END) + self.edsm_user.insert(0, config_idx is not None and config.get('edsm_usernames')[config_idx] or '') + self.edsm_apikey['state'] = tk.NORMAL + self.edsm_apikey.delete(0, tk.END) + self.edsm_apikey.insert(0, config_idx is not None and config.get('edsm_apikeys')[config_idx] 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': @@ -474,9 +516,20 @@ 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 not config.get('cmdrs'): + config.set('cmdrs', [self.cmdr]) + config.set('fdev_usernames', [self.username.get().strip()]) + config.set('fdev_passwords', [self.password.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('fdev_passwords', idx, self.password.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) + @@ -489,9 +542,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 @@ -621,3 +671,35 @@ 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('cmdrs', [current_cmdr]) + config.set('fdev_usernames', [config.get('username')]) + config.set('fdev_passwords', [config.get('password')]) + 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('fdev_passwords', 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/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() From 25e5f27c3a4c7b537cd08b9d0fa52d6add2facf5 Mon Sep 17 00:00:00 2001 From: Jonathan Harris <jonathan@marginal.org.uk> Date: Sun, 22 Jan 2017 12:41:23 +0000 Subject: [PATCH 02/13] One instance of app per user account on Windows --- EDMarketConnector.py | 38 ++++++++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index c57ef31b..a2083edb 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -832,14 +832,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) From ce744a11b043f158b587ec84e3ec80e9d17047a3 Mon Sep 17 00:00:00 2001 From: Jonathan Harris <jonathan@marginal.org.uk> Date: Sun, 22 Jan 2017 17:47:23 +0000 Subject: [PATCH 03/13] Document multiple instances of app --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 4321e30e..5af796da 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,11 @@ This problem is tracked as [Issue #165](https://github.com/Marginal/EDMarketConn ### 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. You will be able to edit these values once you've entered the game. +### I run two instances of E:D simultaneously, but I can't run two instances of EDMC +EDMC supports this scenario of 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. From 94db336aa6b18274f0e7af2cbd73fa4307b830b1 Mon Sep 17 00:00:00 2001 From: Jonathan Harris <jonathan@marginal.org.uk> Date: Mon, 23 Jan 2017 01:49:30 +0000 Subject: [PATCH 04/13] Obtain Cmdr from API for migration --- EDMarketConnector.py | 35 ++++++++++++++++++++--------------- prefs.py | 22 ++++++++++++---------- 2 files changed, 32 insertions(+), 25 deletions(-) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index a2083edb..c29139bc 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -275,6 +275,15 @@ class AppWindow: if not getattr(sys, 'frozen', False): self.updater.checkForUpdates() # Sparkle / WinSparkle does this automatically for packaged apps + # 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 # Try to obtain exclusive lock on journal cache, even if we don't need it yet @@ -332,7 +341,7 @@ class AppWindow: self.button['state'] = self.theme_button['state'] = tk.DISABLED self.w.update_idletasks() try: - if not monitor.cmdr or monitor.cmdr not in config.get('cmdrs'): + 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) self.session.login(config.get('fdev_usernames')[idx], config.get('fdev_passwords')[idx]) @@ -563,18 +572,6 @@ class AppWindow: self.edsm.link(monitor.system) self.w.update_idletasks() - # New Cmdr? - if entry['event'] in [None, 'NewCommander', 'LoadGame'] and monitor.cmdr and not monitor.is_beta: - prefs.migrate(monitor.cmdr) # migration from <= 2.25 - if config.get('cmdrs') and monitor.cmdr in config.get('cmdrs'): - prefs.make_current(monitor.cmdr) - 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 new Cmdr - # Send interesting events to EDSM if config.getint('output') & config.OUT_SYS_EDSM and not monitor.is_beta: self.status['text'] = _('Sending data to EDSM...') @@ -617,8 +614,16 @@ class AppWindow: self.edsmpoll() # Companion login - do this after EDSM so any EDSM errors don't mask login errors - if entry['event'] in [None, 'LoadGame'] and monitor.cmdr and not monitor.is_beta: - self.login() + 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 diff --git a/prefs.py b/prefs.py index 9510b21a..0db73e89 100644 --- a/prefs.py +++ b/prefs.py @@ -341,24 +341,26 @@ class PreferencesDialog(tk.Toplevel): self.cmdr_text['text'] = self.edsm_cmdr_text['text'] = monitor.cmdr and monitor.is_beta and ('%s [Beta]' % monitor.cmdr) or monitor.cmdr or _('None') # No hotkey/shortcut currently defined if self.cmdr != monitor.cmdr: # Cmdr has changed - update settings - if not monitor.is_beta: - migrate(monitor.cmdr) # migration from <= 2.25 - if monitor.cmdr and config.get('cmdrs') and monitor.cmdr in config.get('cmdrs'): - config_idx = config.get('cmdrs').index(monitor.cmdr) - else: - config_idx = None self.username['state'] = tk.NORMAL self.username.delete(0, tk.END) - self.username.insert(0, config_idx is not None and config.get('fdev_usernames')[config_idx] or '') self.password['state'] = tk.NORMAL self.password.delete(0, tk.END) - self.password.insert(0, config_idx is not None and config.get('fdev_passwords')[config_idx] or '') self.edsm_user['state'] = tk.NORMAL self.edsm_user.delete(0, tk.END) - self.edsm_user.insert(0, config_idx is not None and config.get('edsm_usernames')[config_idx] or '') self.edsm_apikey['state'] = tk.NORMAL self.edsm_apikey.delete(0, tk.END) - self.edsm_apikey.insert(0, config_idx is not None and config.get('edsm_apikeys')[config_idx] or '') + 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('fdev_passwords')[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')[config_idx] or '') + self.password.insert(0, config.get('password')[config_idx] or '') + self.edsm_user.insert(0,config.get('edsm_cmdrname')[config_idx] or '') + self.edsm_apikey.insert(0, config.get('edsm_apikey')[config_idx] or '') self.cmdr = monitor.cmdr cmdr_state = not monitor.is_beta and monitor.cmdr and tk.NORMAL or tk.DISABLED From 445786a06f7df26f190384cae3e341c9ca7fc45b Mon Sep 17 00:00:00 2001 From: Jonathan Harris <jonathan@marginal.org.uk> Date: Mon, 23 Jan 2017 01:51:03 +0000 Subject: [PATCH 05/13] Fail silently if EDSM Cmdr blank --- EDMarketConnector.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index c29139bc..f541338a 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -573,7 +573,7 @@ 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: From aed46804856e25138eb5aac4bb58ec7aabc18064 Mon Sep 17 00:00:00 2001 From: Jonathan Harris <jonathan@marginal.org.uk> Date: Tue, 24 Jan 2017 23:07:56 +0000 Subject: [PATCH 06/13] Display Beta status in settings even if no Cmdr --- README.md | 2 +- prefs.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 5af796da..1cca006f 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ 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. You will be able to edit these values once you've entered the game. +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 of 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. diff --git a/prefs.py b/prefs.py index 0db73e89..5fb95e10 100644 --- a/prefs.py +++ b/prefs.py @@ -80,7 +80,7 @@ class PreferencesDialog(tk.Toplevel): parent.call('tk::unsupported::MacWindowStyle', 'style', self, 'utility') self.resizable(tk.FALSE, tk.FALSE) - self.cmdr = None # Note if Cmdr changes in the Journal + self.cmdr = False # Note if Cmdr changes in the Journal frame = ttk.Frame(self) frame.grid(sticky=tk.NSEW) @@ -106,7 +106,7 @@ class PreferencesDialog(tk.Toplevel): 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, text=_('None')) # No hotkey/shortcut currently defined + 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.grid(row=11, column=1, padx=PADX, pady=PADY, sticky=tk.EW) @@ -194,7 +194,7 @@ class PreferencesDialog(tk.Toplevel): 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_text = nb.Label(edsmframe, text=_('None')) # No hotkey/shortcut currently defined + 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 @@ -338,7 +338,7 @@ class PreferencesDialog(tk.Toplevel): 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 and monitor.is_beta and ('%s [Beta]' % monitor.cmdr) or monitor.cmdr or _('None') # No hotkey/shortcut currently defined + 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 From 00f9dfb8dd1b9bd7816c244ef52d93c81d612584 Mon Sep 17 00:00:00 2001 From: Jonathan Harris <jonathan@marginal.org.uk> Date: Tue, 24 Jan 2017 23:08:57 +0000 Subject: [PATCH 07/13] Release 2.26 beta 1 --- config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.py b/config.py index 19a7bc9b..60107a23 100644 --- a/config.py +++ b/config.py @@ -8,7 +8,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 From 178b0b506e1ba198945c69e6ef0a2e83a122770c Mon Sep 17 00:00:00 2001 From: Jonathan Harris <jonathan@marginal.org.uk> Date: Tue, 24 Jan 2017 23:25:15 +0000 Subject: [PATCH 08/13] Ignore backups of edited Journal files --- monitor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/monitor.py b/monitor.py index b7206ba1..53a96944 100644 --- a/monitor.py +++ b/monitor.py @@ -165,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): @@ -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() From 71850b5ddb21c7623a0a5ad31fc19960a87157ec Mon Sep 17 00:00:00 2001 From: Jonathan Harris <jonathan@marginal.org.uk> Date: Wed, 25 Jan 2017 01:09:17 +0000 Subject: [PATCH 09/13] Typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1cca006f..83d63a94 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ This problem is tracked as [Issue #165](https://github.com/Marginal/EDMarketConn 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 of 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 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. From a247ace6c8cb62e970c83ca7b3b411c2d9ae7989 Mon Sep 17 00:00:00 2001 From: Jonathan Harris <jonathan@marginal.org.uk> Date: Fri, 27 Jan 2017 12:52:21 +0000 Subject: [PATCH 10/13] Fix for retrieving lists on Linux --- config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config.py b/config.py index 60107a23..be76293d 100644 --- a/config.py +++ b/config.py @@ -298,7 +298,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 +314,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() From ca7d0f9c4cce86dcaa8b79e0dc7791107f205b23 Mon Sep 17 00:00:00 2001 From: Jonathan Harris <jonathan@marginal.org.uk> Date: Fri, 27 Jan 2017 13:32:51 +0000 Subject: [PATCH 11/13] Save error log to tempdir on OSX --- EDMarketConnector.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index f541338a..dd853f60 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -28,8 +28,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 From a3949c17c879b1555d83bc82546fc8a5f7a4c343 Mon Sep 17 00:00:00 2001 From: Jonathan Harris <jonathan@marginal.org.uk> Date: Fri, 27 Jan 2017 13:38:40 +0000 Subject: [PATCH 12/13] Use keyring for password storage Fixes #89 --- EDMC.py | 3 ++- EDMarketConnector.py | 12 +++++++++++- README.md | 6 +++--- config.py | 29 ++++++++++++++++++++++++----- prefs.py | 19 ++++++++++--------- setup.py | 4 ++-- 6 files changed, 52 insertions(+), 21 deletions(-) diff --git a/EDMC.py b/EDMC.py index d720c809..126e1b5a 100755 --- a/EDMC.py +++ b/EDMC.py @@ -83,7 +83,8 @@ try: else: session = companion.Session() if config.get('cmdrs'): - session.login(config.get('fdev_usernames')[0], config.get('fdev_passwords')[0]) + 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()) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index dd853f60..de0cf687 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 @@ -276,6 +277,11 @@ class AppWindow: if not getattr(sys, 'frozen', False): 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 <= 2.25 if not config.get('cmdrs') and config.get('username') and config.get('password'): try: @@ -287,6 +293,9 @@ class AppWindow: 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 @@ -345,7 +354,8 @@ class AppWindow: 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) - self.session.login(config.get('fdev_usernames')[idx], config.get('fdev_passwords')[idx]) + 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)) diff --git a/README.md b/README.md index 83d63a94..e754840e 100644 --- a/README.md +++ b/README.md @@ -165,17 +165,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 diff --git a/config.py b/config.py index be76293d..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 @@ -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)) @@ -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/prefs.py b/prefs.py index 5fb95e10..a96a10a8 100644 --- a/prefs.py +++ b/prefs.py @@ -352,15 +352,15 @@ class PreferencesDialog(tk.Toplevel): 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('fdev_passwords')[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')[config_idx] or '') - self.password.insert(0, config.get('password')[config_idx] or '') - self.edsm_user.insert(0,config.get('edsm_cmdrname')[config_idx] or '') - self.edsm_apikey.insert(0, config.get('edsm_apikey')[config_idx] or '') + 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 @@ -519,17 +519,19 @@ class PreferencesDialog(tk.Toplevel): def apply(self): 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('fdev_passwords', [self.password.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('fdev_passwords', idx, self.password.get().strip()) _putfirst('edsm_usernames', idx, self.edsm_user.get().strip()) _putfirst('edsm_apikeys', idx, self.edsm_apikey.get().strip()) @@ -677,9 +679,9 @@ class AuthenticationDialog(tk.Toplevel): # 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('fdev_passwords', [config.get('password')]) config.set('edsm_usernames', [config.get('edsm_cmdrname') or '']) config.set('edsm_apikeys', [config.get('edsm_apikey') or '']) # XXX to be done for release @@ -694,7 +696,6 @@ def make_current(current_cmdr): idx = config.get('cmdrs').index(current_cmdr) _putfirst('cmdrs', idx) _putfirst('fdev_usernames', idx) - _putfirst('fdev_passwords', idx) _putfirst('edsm_usernames', idx) _putfirst('edsm_apikeys', idx) 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' ], } } From 2952fc09cbcbb99bef7b42323ebc07f847497a1c Mon Sep 17 00:00:00 2001 From: Jonathan Harris <jonathan@marginal.org.uk> Date: Fri, 27 Jan 2017 14:43:55 +0000 Subject: [PATCH 13/13] Document use of keyring --- README.md | 1 + requirements | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index e754840e..5da74f79 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,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/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