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