From f17f5d3f257e2359d4728ce8c296d70dc5e7609f Mon Sep 17 00:00:00 2001 From: Jonathan Harris Date: Fri, 28 Dec 2018 02:14:03 +0000 Subject: [PATCH] PKCE OAuth2 access to cAPI --- EDMC.py | 17 +--- EDMarketConnector.py | 77 ++++++---------- EDMarketConnector.wxs | 25 +++++ L10n/en.template | 32 ------- companion.py | 206 +++++++++++++++++++++++++++--------------- prefs.py | 93 ------------------- protocol.py | 194 +++++++++++++++++++++++++++++++++++++++ setup.py | 8 ++ stats.py | 3 - 9 files changed, 393 insertions(+), 262 deletions(-) create mode 100644 protocol.py diff --git a/EDMC.py b/EDMC.py index 76d5e453..d4861559 100755 --- a/EDMC.py +++ b/EDMC.py @@ -109,17 +109,13 @@ try: if cmdr.lower() == args.p.lower(): break else: - raise companion.CredentialsError - username = config.get('fdev_usernames')[idx] - companion.session.login(username, config.get_password(username), monitor.is_beta) - elif config.get('cmdrs'): + raise companion.CredentialsError() + companion.session.login(cmdr, monitor.is_beta) + else: cmdrs = config.get('cmdrs') or [] if monitor.cmdr not in cmdrs: - raise companion.CredentialsError - username = config.get('fdev_usernames')[cmdrs.index(monitor.cmdr)] - companion.session.login(username, config.get_password(username), monitor.is_beta) - else: # <= 2.25 not yet migrated - companion.session.login(config.get('username'), config.get('password'), monitor.is_beta) + raise companion.CredentialsError() + companion.session.login(monitor.cmdr, monitor.is_beta) querytime = int(time()) data = companion.session.station() config.set('querytime', querytime) @@ -237,6 +233,3 @@ except companion.SKUError as e: except companion.CredentialsError as e: sys.stderr.write('Invalid Credentials\n') sys.exit(EXIT_CREDENTIALS) -except companion.VerificationRequired: - sys.stderr.write('Verification Required\n') - sys.exit(EXIT_VERIFICATION) diff --git a/EDMarketConnector.py b/EDMarketConnector.py index 9350fd4c..bd9788d2 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -53,6 +53,7 @@ import prefs import plug from hotkey import hotkeymgr from monitor import monitor +from protocol import ProtocolHandler from dashboard import dashboard from theme import theme @@ -69,6 +70,9 @@ class AppWindow: def __init__(self, master): + # Start a protocol handler to handle cAPI registration + self.protocolhandler = ProtocolHandler(master) + self.holdofftime = config.getint('querytime') + companion.holdoff self.w = master @@ -76,7 +80,6 @@ class AppWindow: self.w.rowconfigure(0, weight=1) self.w.columnconfigure(0, weight=1) - self.authdialog = None self.prefsdialog = None plug.load_plugins(master) @@ -275,6 +278,7 @@ class AppWindow: self.w.bind_all('<>', self.journal_event) # Journal monitoring self.w.bind_all('<>', self.dashboard_event) # Dashboard monitoring self.w.bind_all('<>', self.plugin_error) # Statusbar + self.w.bind_all('<>', self.auth) # cAPI auth self.w.bind_all('<>', self.onexit) # Updater # Load updater after UI creation (for WinSparkle) @@ -288,14 +292,10 @@ class AppWindow: except RuntimeError: pass - # Migration from <= 2.25 - if not config.get('cmdrs') and config.get('username') and config.get('password'): - try: - companion.session.login(config.get('username'), config.get('password'), False) - data = companion.session.profile() - prefs.migrate(data['commander']['name']) - except: - if __debug__: print_exc() + # Migration from <= 3.30 + for username in config.get('fdev_usernames') or []: + config.delete_password(username) + config.delete('fdev_usernames') config.delete('username') config.delete('password') config.delete('logdir') @@ -366,15 +366,8 @@ class AppWindow: self.button['state'] = self.theme_button['state'] = tk.DISABLED self.w.update_idletasks() try: - if not monitor.cmdr or not config.get('cmdrs') or monitor.cmdr not in config.get('cmdrs'): - raise companion.CredentialsError() - idx = config.get('cmdrs').index(monitor.cmdr) - username = config.get('fdev_usernames')[idx] - companion.session.login(username, config.get_password(username), monitor.is_beta) - self.status['text'] = '' - except companion.VerificationRequired: - if not self.authdialog: - self.authdialog = prefs.AuthenticationDialog(self.w, partial(self.verify, self.login)) + if companion.session.login(monitor.cmdr, monitor.is_beta): + self.status['text'] = '' except companion.ServerError as e: self.status['text'] = unicode(e) except Exception as e: @@ -382,19 +375,6 @@ class AppWindow: self.status['text'] = unicode(e) self.cooldown() - # callback after verification code - def verify(self, callback, code): - self.authdialog = None - try: - companion.session.verify(code) - config.save() # Save settings now for use by command-line app - except Exception as e: - if __debug__: print_exc() - self.button['state'] = self.theme_button['state'] = tk.NORMAL - self.status['text'] = unicode(e) - else: - return callback() # try again - def getandsend(self, event=None, retrying=False): auto_update = not event @@ -481,10 +461,6 @@ class AppWindow: if config.getint('output') & config.OUT_MKT_TD: td.export(fixed) - except companion.VerificationRequired: - if not self.authdialog: - self.authdialog = prefs.AuthenticationDialog(self.w, partial(self.verify, self.getandsend)) - # Companion API problem except (companion.ServerError, companion.ServerLagging) as e: if retrying: @@ -570,15 +546,10 @@ class AppWindow: # Companion login if entry['event'] in [None, 'StartUp', 'NewCommander', 'LoadGame'] and monitor.cmdr: - if config.get('cmdrs') and monitor.cmdr in config.get('cmdrs'): - prefs.make_current(monitor.cmdr) + if not config.get('cmdrs') or monitor.cmdr not in config.get('cmdrs'): + config.set('cmdrs', (config.get('cmdrs') or []) + [monitor.cmdr]) + if config.getint('output') & config.OUT_MKT_EDDN: 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) - elif not self.prefsdialog: - self.prefsdialog = prefs.PreferencesDialog(self.w, self.postprefs) # First run or failed migration if not entry['event'] or not monitor.mode: return # Startup or in CQC @@ -603,6 +574,18 @@ class AppWindow: if entry['event'] in ['StartUp', 'Location', 'Docked'] and monitor.station and not config.getint('output') & config.OUT_MKT_MANUAL and config.getint('output') & config.OUT_STATION_ANY: self.w.after(int(SERVER_RETRY * 1000), self.getandsend) + # cAPI auth + def auth(self, event=None): + try: + companion.session.auth_callback(self.protocolhandler.lastpayload) + self.status['text'] = '' + except companion.ServerError as e: + self.status['text'] = unicode(e) + except Exception as e: + if __debug__: print_exc() + self.status['text'] = unicode(e) + self.cooldown() + # Handle Status event def dashboard_event(self, event): entry = dashboard.status @@ -673,9 +656,6 @@ class AppWindow: if f: with open(f, 'wt') as h: h.write(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True, separators=(',', ': ')).encode('utf-8')) - except companion.VerificationRequired: - if not self.authdialog: - self.authdialog = prefs.AuthenticationDialog(self.w, partial(self.verify, self.save_raw)) except companion.ServerError as e: self.status['text'] = str(e) except Exception as e: @@ -686,6 +666,7 @@ class AppWindow: if platform!='darwin' or self.w.winfo_rooty()>0: # http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7 config.set('geometry', '+{1}+{2}'.format(*self.w.geometry().split('+'))) self.w.withdraw() # Following items can take a few seconds, so hide the main window while they happen + self.protocolhandler.close() hotkeymgr.unregister() dashboard.close() monitor.close() @@ -736,7 +717,6 @@ if __name__ == "__main__": import ctypes 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 @@ -754,6 +734,7 @@ if __name__ == "__main__": return buf.value return None + @ctypes.WINFUNCTYPE(BOOL, HWND, LPARAM) 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) @@ -764,7 +745,7 @@ if __name__ == "__main__": sys.exit(0) return True - EnumWindows(EnumWindowsProc(enumwindowsproc), 0) + EnumWindows(enumwindowsproc, 0) if getattr(sys, 'frozen', False): # By default py2exe tries to write log to dirname(sys.executable) which fails when installed diff --git a/EDMarketConnector.wxs b/EDMarketConnector.wxs index da1d3d9a..7f3b723f 100644 --- a/EDMarketConnector.wxs +++ b/EDMarketConnector.wxs @@ -90,6 +90,30 @@ + + + + + + + + + + + + + + + + + + + + + + @@ -476,6 +500,7 @@ + diff --git a/L10n/en.template b/L10n/en.template index 3f42fe75..61ab1f7b 100644 --- a/L10n/en.template +++ b/L10n/en.template @@ -58,9 +58,6 @@ /* Main window. [EDMarketConnector.py] */ "Cmdr" = "Cmdr"; -/* Privacy setting. [prefs.py] */ -"Cmdr name" = "Cmdr name"; - /* Ranking. [stats.py] */ "Combat" = "Combat"; @@ -85,9 +82,6 @@ /* Ranking. [stats.py] */ "CQC" = "CQC"; -/* Section heading in settings. [prefs.py] */ -"Credentials" = "Credentials"; - /* Combat rank. [stats.py] */ "Dangerous" = "Dangerous"; @@ -169,9 +163,6 @@ /* [companion.py] */ "Error: Invalid Credentials" = "Error: Invalid Credentials"; -/* [companion.py] */ -"Error: Verification failed" = "Error: Verification failed"; - /* Raised when the user has multiple accounts and the username/password setting is not for the account they're currently playing OR the user has reset their Cmdr and the Companion API server is still returning data for the old Cmdr. [companion.py] */ "Error: Wrong Cmdr" = "Error: Wrong Cmdr"; @@ -226,11 +217,6 @@ /* Hotkey/Shortcut settings prompt on Windows. [prefs.py] */ "Hotkey" = "Hotkey"; -/* [prefs.py] */ -"How do you want to be identified in the saved data" = "How do you want to be identified in the saved data"; - -/* Tab heading in settings. [prefs.py] */ -"Identity" = "Identity"; /* Section heading in settings. [inara.py] */ "Inara credentials" = "Inara credentials"; @@ -301,9 +287,6 @@ /* Dark theme color setting. [prefs.py] */ "Normal text" = "Normal text"; -/* Displayed when credentials settings are greyed out. [prefs.py] */ -"Not available while E:D is at the main menu" = "Not available while E:D is at the main menu"; - /* Combat rank. [stats.py] */ "Novice" = "Novice"; @@ -325,9 +308,6 @@ /* Empire rank. [stats.py] */ "Outsider" = "Outsider"; -/* Use same text as E:D Launcher's login dialog. [prefs.py] */ -"Password" = "Password"; - /* Explorer rank. [stats.py] */ "Pathfinder" = "Pathfinder"; @@ -352,9 +332,6 @@ /* Use same text as E:D Launcher's verification dialog. [prefs.py] */ "Please enter the code into the box below." = "Please enter the code into the box below."; -/* Use same text as E:D Launcher's login dialog. [prefs.py] */ -"Please log in with your Elite: Dangerous account details" = "Please log in with your Elite: Dangerous account details"; - /* Tab heading in settings. [prefs.py] */ "Plugins" = "Plugins"; @@ -379,15 +356,9 @@ /* Empire rank. [stats.py] */ "Prince" = "Prince"; -/* Section heading in settings. [prefs.py] */ -"Privacy" = "Privacy"; - /* CQC rank. [stats.py] */ "Professional" = "Professional"; -/* Privacy setting. [prefs.py] */ -"Pseudo-anonymized ID" = "Pseudo-anonymized ID"; - /* Explorer rank. [stats.py] */ "Ranger" = "Ranger"; @@ -505,9 +476,6 @@ /* Update button in main window. [EDMarketConnector.py] */ "Update" = "Update"; -/* Use same text as E:D Launcher's login dialog. [prefs.py] */ -"Username (Email)" = "Username (Email)"; - /* Status dialog subtitle - CR value of ship. [stats.py] */ "Value" = "Value"; diff --git a/companion.py b/companion.py index b8a3fac6..ef81bb2f 100644 --- a/companion.py +++ b/companion.py @@ -1,24 +1,32 @@ +import base64 import csv import requests -from cookielib import LWPCookieJar +from cookielib import LWPCookieJar # No longer needed but retained in case plugins use it from email.utils import parsedate import hashlib +import json import numbers import os from os.path import dirname, isfile, join import sys import time from traceback import print_exc +import urlparse +import webbrowser +import zlib from config import appname, appversion, config holdoff = 60 # be nice timeout = 10 # requests timeout +CLIENT_ID = None # Replace with FDev Client Id +SERVER_AUTH = 'https://auth.frontierstore.net' +URL_AUTH = '/auth' +URL_TOKEN = '/token' + SERVER_LIVE = 'https://companion.orerve.net' SERVER_BETA = 'https://pts-companion.orerve.net' -URL_LOGIN = '/user/login' -URL_CONFIRM = '/user/confirm' URL_QUERY = '/profile' URL_MARKET = '/market' URL_SHIPYARD= '/shipyard' @@ -136,24 +144,94 @@ class CmdrError(Exception): def __str__(self): return unicode(self).encode('utf-8') -class VerificationRequired(Exception): - def __unicode__(self): - return _('Error: Verification failed') - def __str__(self): - return unicode(self).encode('utf-8') +class Auth: + + def __init__(self, cmdr): + self.cmdr = cmdr + self.session = requests.Session() + self.session.headers['User-Agent'] = 'EDCD-%s-%s' % (appname, appversion) + self.verifier = self.state = None + + def refresh(self): + # Try refresh token + self.verifier = None + cmdrs = config.get('cmdrs') + idx = cmdrs.index(self.cmdr) + tokens = config.get('fdev_apikeys') or [] + tokens = tokens + [''] * (len(cmdrs) - len(tokens)) + if tokens[idx]: + data = { + 'grant_type': 'refresh_token', + 'client_id': CLIENT_ID, + 'refresh_token': tokens[idx], + } + r = self.session.post(SERVER_AUTH + URL_TOKEN, data=data, timeout=timeout) + if r.status_code == requests.codes.ok: + data = r.json() + tokens[idx] = data.get('refresh_token', '') + config.set('fdev_apikeys', tokens) + config.save() # Save settings now for use by command-line app + return data.get('access_token') + else: + self.dump(r) + + # New request + self.verifier = self.base64URLEncode(os.urandom(32)) + self.state = self.base64URLEncode(os.urandom(8)) # Keep small to stay under 256 ShellExecute limit on Windows + webbrowser.open('%s%s?response_type=code&approval_prompt=auto&client_id=%s&code_challenge=%s&code_challenge_method=S256&state=%s&redirect_uri=edmc://auth' % (SERVER_AUTH, URL_AUTH, CLIENT_ID, self.base64URLEncode(hashlib.sha256(self.verifier).digest()), self.state)) + + def authorize(self, payload): + if not self.verifier or not self.state or not '?' in payload: + raise CredentialsError() + try: + data = urlparse.parse_qs(payload[payload.index('?')+1:]) + if data['state'][0] != self.state: + raise CredentialsError() + code = data['code'][0] + except: + if __debug__: print_exc() + raise CredentialsError() + + data = { + 'grant_type': 'authorization_code', + 'client_id': CLIENT_ID, + 'code_verifier': self.verifier, + 'code': code, + 'redirect_uri': 'edmc://auth', + } + r = self.session.post(SERVER_AUTH + URL_TOKEN, data=data, timeout=timeout) + if r.status_code == requests.codes.ok: + data = r.json() + cmdrs = config.get('cmdrs') + idx = cmdrs.index(self.cmdr) + tokens = config.get('fdev_apikeys') or [] + tokens = tokens + [''] * (len(cmdrs) - len(tokens)) + tokens[idx] = data.get('refresh_token', '') + config.set('fdev_apikeys', tokens) + config.save() # Save settings now for use by command-line app + return data.get('access_token') + else: + self.dump(r) + return None + + def dump(self, r): + print_exc() + print 'Auth\t' + r.url, r.status_code, r.headers, r.text.encode('utf-8') + + def base64URLEncode(self, text): + return base64.urlsafe_b64encode(text).replace('=', '') -# Server companion.orerve.net uses a session cookie ("CompanionApp") to tie together login, verification -# and query. So route all requests through a single Session object which holds this state. class Session: - STATE_NONE, STATE_INIT, STATE_AUTH, STATE_OK = range(4) + STATE_INIT, STATE_AUTH, STATE_OK = range(3) def __init__(self): self.state = Session.STATE_INIT self.credentials = None self.session = None + self.auth = None # yuck suppress InsecurePlatformWarning under Python < 2.7.9 which lacks SNI support if sys.version_info < (2,7,9): @@ -163,87 +241,68 @@ class Session: if getattr(sys, 'frozen', False): os.environ['REQUESTS_CA_BUNDLE'] = join(config.respath, 'cacert.pem') - def login(self, username=None, password=None, is_beta=False): - if (not username or not password): - if not self.credentials: - raise CredentialsError() - else: - credentials = self.credentials - else: - credentials = { 'email' : username, 'password' : password, 'beta' : is_beta } - + def login(self, cmdr, is_beta=False): + if not CLIENT_ID: + raise CredentialsError() + credentials = {'cmdr': cmdr, 'beta': is_beta} if self.credentials == credentials and self.state == Session.STATE_OK: - return # already logged in + return True # already logged in - if not self.credentials or self.credentials['email'] != credentials['email'] or self.credentials['beta'] != credentials['beta']: + if not self.credentials or self.credentials['cmdr'] != credentials['cmdr'] or self.credentials['beta'] != credentials['beta']: # changed account self.close() - self.session = requests.Session() - self.session.headers['User-Agent'] = 'EDCD-%s-%s' % (appname, appversion) - 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.server = credentials['beta'] and SERVER_BETA or SERVER_LIVE self.credentials = credentials + self.server = credentials['beta'] and SERVER_BETA or SERVER_LIVE self.state = Session.STATE_INIT - try: - r = self.session.post(self.server + URL_LOGIN, data = self.credentials, timeout=timeout) - except: - if __debug__: print_exc() - raise ServerError() - - if r.status_code != requests.codes.ok or 'server error' in r.text: - self.dump(r) - raise ServerError() - elif r.url == self.server + URL_LOGIN: # would have redirected away if success - self.dump(r) - if 'purchase' in r.text.lower(): - raise SKUError() - else: - raise CredentialsError() - elif r.url == self.server + URL_CONFIRM: # redirected to verification page - self.state = Session.STATE_AUTH - raise VerificationRequired() + self.auth = Auth(cmdr) + access_token = self.auth.refresh() + if access_token: + self.auth = None + self.start(access_token) + return True else: - self.state = Session.STATE_OK - return r.status_code + self.state = Session.STATE_AUTH + return False + # Wait for callback - def verify(self, code): - if not code: - raise VerificationRequired() - r = self.session.post(self.server + URL_CONFIRM, data = {'code' : code}, timeout=timeout) - if r.status_code != requests.codes.ok or r.url == self.server + URL_CONFIRM: # would have redirected away if success - raise VerificationRequired() - self.session.cookies.save() # Save cookies now for use by command-line app - self.login() + # Callback from protocol handler + def auth_callback(self, payload): + if self.state != Session.STATE_AUTH: + raise CredentialsError() # Shouldn't be getting a callback + access_token = self.auth.authorize(payload) + if access_token: + self.auth = None + self.start(access_token) + else: + self.state = Session.STATE_INIT + raise CredentialsError() # Bad thing happened + + def start(self, access_token): + self.session = requests.Session() + self.session.headers['Authorization'] = 'Bearer %s' % access_token + self.session.headers['User-Agent'] = 'EDCD-%s-%s' % (appname, appversion) + self.state = Session.STATE_OK def query(self, endpoint): - if self.state == Session.STATE_NONE: - raise Exception('General error') # Shouldn't happen - don't bother localizing - elif self.state == Session.STATE_INIT: - self.login() + if self.state == Session.STATE_INIT: + if self.login(): + return self.query(endpoint) elif self.state == Session.STATE_AUTH: - raise VerificationRequired() + raise CredentialsError() + try: r = self.session.get(self.server + endpoint, timeout=timeout) except: if __debug__: print_exc() raise ServerError() - if r.status_code == requests.codes.forbidden or r.url == self.server + URL_LOGIN: - # Start again - maybe our session cookie expired? - self.state = Session.STATE_INIT - return self.query(endpoint) - if r.status_code != requests.codes.ok: + # Start again - maybe our token expired self.dump(r) - raise ServerError() + self.close() + if self.login(self.credentials['cmdr'], self.credentials['beta']): + return self.query(endpoint) try: data = r.json() @@ -279,10 +338,9 @@ class Session: return data def close(self): - self.state = Session.STATE_NONE + self.state = Session.STATE_INIT if self.session: try: - self.session.cookies.save() self.session.close() except: if __debug__: print_exc() diff --git a/prefs.py b/prefs.py index 14deb259..71079a89 100644 --- a/prefs.py +++ b/prefs.py @@ -98,41 +98,6 @@ class PreferencesDialog(tk.Toplevel): BUTTONX = 12 # indent Checkbuttons and Radiobuttons PADY = 2 # close spacing - credframe = nb.Frame(notebook) - credframe.columnconfigure(1, weight=1) - - 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) - self.cred_label = nb.Label(credframe) - self.cred_label.grid(padx=PADX, columnspan=2, sticky=tk.W) - self.cmdr_label = nb.Label(credframe, text=_('Cmdr')) # Main window - self.cmdr_label.grid(row=10, padx=PADX, sticky=tk.W) - self.username_label = nb.Label(credframe, text=_('Username (Email)')) # Use same text as E:D Launcher's login dialog - self.username_label.grid(row=11, padx=PADX, sticky=tk.W) - self.password_label = nb.Label(credframe, text=_('Password')) # Use same text as E:D Launcher's login dialog - self.password_label.grid(row=12, padx=PADX, sticky=tk.W) - - self.cmdr_text = nb.Label(credframe) - self.cmdr_text.grid(row=10, column=1, padx=PADX, pady=PADY, sticky=tk.W) - self.username = nb.Entry(credframe) - self.username.grid(row=11, column=1, padx=PADX, pady=PADY, sticky=tk.EW) - if monitor.cmdr: - self.username.focus_set() - self.password = nb.Entry(credframe, show=u'•') - 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 - ttk.Separator(credframe, orient=tk.HORIZONTAL).grid(columnspan=2, padx=PADX, pady=PADY, sticky=tk.EW) - - self.out_anon= tk.IntVar(value = config.getint('anonymous') and 1) - nb.Label(credframe, text=_('How do you want to be identified in the saved data')).grid(columnspan=2, padx=PADX, sticky=tk.W) - nb.Radiobutton(credframe, text=_('Cmdr name'), variable=self.out_anon, value=0).grid(columnspan=2, padx=BUTTONX, sticky=tk.W) # Privacy setting - nb.Radiobutton(credframe, text=_('Pseudo-anonymized ID'), variable=self.out_anon, value=1).grid(columnspan=2, padx=BUTTONX, sticky=tk.W) # Privacy setting - - notebook.add(credframe, text=_('Identity')) # Tab heading in settings - - outframe = nb.Frame(notebook) outframe.columnconfigure(0, weight=1) @@ -351,25 +316,6 @@ class PreferencesDialog(tk.Toplevel): def cmdrchanged(self, event=None): if self.cmdr != monitor.cmdr or self.is_beta != monitor.is_beta: # Cmdr has changed - update settings - if monitor.cmdr: - self.cred_label['text'] = _('Please log in with your Elite: Dangerous account details') # Use same text as E:D Launcher's login dialog - else: - self.cred_label['text'] = _('Not available while E:D is at the main menu') # Displayed when credentials settings are greyed out - - self.cmdr_label['state'] = self.username_label['state'] = self.password_label['state'] = self.cmdr_text['state'] = self.username['state'] = self.password['state'] = monitor.cmdr and tk.NORMAL or tk.DISABLED - self.cmdr_text['text'] = (monitor.cmdr or _('None')) + (monitor.is_beta and ' [Beta]' or '') # No hotkey/shortcut currently defined - self.username['state'] = tk.NORMAL - self.username.delete(0, tk.END) - self.password['state'] = tk.NORMAL - self.password.delete(0, tk.END) - if monitor.cmdr and config.get('cmdrs') and monitor.cmdr in config.get('cmdrs'): - config_idx = config.get('cmdrs').index(monitor.cmdr) - self.username.insert(0, config.get('fdev_usernames')[config_idx] or '') - self.password.insert(0, config.get_password(config.get('fdev_usernames')[config_idx]) or '') - elif monitor.cmdr and not config.get('cmdrs') and config.get('username') and config.get('password'): - # migration from <= 2.25 - self.username.insert(0, config.get('username') or '') - self.password.insert(0, config.get('password') or '') if self.cmdr is not False: # Don't notify on first run plug.notify_prefs_cmdr_changed(monitor.cmdr, monitor.is_beta) self.cmdr = monitor.cmdr @@ -522,19 +468,6 @@ class PreferencesDialog(tk.Toplevel): def apply(self): - if self.cmdr: - 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()]) - 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()) - config.set('output', (self.out_td.get() and config.OUT_MKT_TD) + (self.out_csv.get() and config.OUT_MKT_CSV) + @@ -568,8 +501,6 @@ class PreferencesDialog(tk.Toplevel): config.set('dark_highlight', self.theme_colors[1]) theme.apply(self.parent) - config.set('anonymous', self.out_anon.get()) - # Notify if self.callback: self.callback() @@ -687,27 +618,3 @@ class AuthenticationDialog(tk.Toplevel): self.parent.wm_attributes('-topmost', config.getint('always_ontop') and 1 or 0) self.destroy() if self.callback: self.callback(None) - -# migration from <= 2.25. Assumes current Cmdr corresponds to the saved credentials -def migrate(current_cmdr): - if current_cmdr and not config.get('cmdrs') and config.get('username') and config.get('password'): - config.set_password(config.get('username'), config.get('password')) # Can fail on Linux - config.set('cmdrs', [current_cmdr]) - config.set('fdev_usernames', [config.get('username')]) - config.delete('username') - config.delete('password') - -# 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) - -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/protocol.py b/protocol.py new file mode 100644 index 00000000..83080265 --- /dev/null +++ b/protocol.py @@ -0,0 +1,194 @@ +# edmc: protocol handler for cAPI authorisation + + +import threading +import urllib2 +from sys import platform + +from config import appname + + +class GenericProtocolHandler: + + def __init__(self, master): + self.master = master + self.lastpayload = None + + def close(self): + pass + + def event(self, url): + if url.startswith('edmc://'): + self.lastpayload = url[7:] + self.master.event_generate('<>', when="tail") + + +if platform == 'darwin': + + import struct + import objc + from AppKit import NSAppleEventManager, NSObject + + kInternetEventClass = kAEGetURL = struct.unpack('>l', b'GURL')[0] + keyDirectObject = struct.unpack('>l', b'----')[0] + + class ProtocolHandler(GenericProtocolHandler): + + POLL = 100 # ms + + def __init__(self, master): + GenericProtocolHandler.__init__(self, master) + self.eventhandler = EventHandler.alloc().init() + self.eventhandler.handler = self + self.lasturl = None + + def poll(self): + # No way of signalling to Tkinter from within the callback handler block that doesn't cause Python to crash, so poll. + if self.lasturl: + self.event(self.lasturl) + self.lasturl = None + + class EventHandler(NSObject): + + def init(self): + self = objc.super(EventHandler, self).init() + NSAppleEventManager.sharedAppleEventManager().setEventHandler_andSelector_forEventClass_andEventID_(self, 'handleEvent:withReplyEvent:', kInternetEventClass, kAEGetURL) + return self + + def handleEvent_withReplyEvent_(self, event, replyEvent): + self.handler.lasturl = urllib2.unquote(event.paramDescriptorForKeyword_(keyDirectObject).stringValue()).strip() + self.handler.master.after(ProtocolHandler.POLL, self.handler.poll) + + +elif platform == 'win32': + + from ctypes import * + from ctypes.wintypes import * + + class WNDCLASS(Structure): + _fields_ = [('style', UINT), + ('lpfnWndProc', WINFUNCTYPE(c_long, HWND, UINT, WPARAM, LPARAM)), + ('cbClsExtra', INT), + ('cbWndExtra', INT), + ('hInstance', HINSTANCE), + ('hIcon', HICON), + ('hCursor', c_void_p), + ('hbrBackground', HBRUSH), + ('lpszMenuName', LPCWSTR), + ('lpszClassName', LPCWSTR) + ] + + CW_USEDEFAULT = 0x80000000 + + CreateWindowEx = windll.user32.CreateWindowExW + CreateWindowEx.argtypes = [DWORD, LPCWSTR, LPCWSTR, DWORD, INT, INT, INT, INT, HWND, HMENU, HINSTANCE, LPVOID] + CreateWindowEx.restype = HWND + RegisterClass = windll.user32.RegisterClassW + RegisterClass.argtypes = [POINTER(WNDCLASS)] + DefWindowProc = windll.user32.DefWindowProcW + + GetMessage = windll.user32.GetMessageW + TranslateMessage = windll.user32.TranslateMessage + DispatchMessage = windll.user32.DispatchMessageW + PostThreadMessage = windll.user32.PostThreadMessageW + SendMessage = windll.user32.SendMessageW + SendMessage.argtypes = [HWND, UINT, WPARAM, LPARAM] + PostMessage = windll.user32.PostMessageW + PostMessage.argtypes = [HWND, UINT, WPARAM, LPARAM] + + WM_QUIT = 0x0012 + WM_DDE_INITIATE = 0x03E0 + WM_DDE_TERMINATE = 0x03E1 + WM_DDE_ACK = 0x03E4 + WM_DDE_EXECUTE = 0x03E8 + + PackDDElParam = windll.user32.PackDDElParam + PackDDElParam.argtypes = [UINT, LPARAM, LPARAM] + + GlobalAddAtom = windll.kernel32.GlobalAddAtomW + GlobalAddAtom.argtypes = [LPWSTR] + GlobalAddAtom.restype = ATOM + GlobalGetAtomName = windll.kernel32.GlobalGetAtomNameW + GlobalGetAtomName.argtypes = [ATOM, LPWSTR, INT] + GlobalGetAtomName.restype = UINT + GlobalLock = windll.kernel32.GlobalLock + GlobalLock.argtypes = [HGLOBAL] + GlobalLock.restype = LPVOID + GlobalUnlock = windll.kernel32.GlobalUnlock + GlobalUnlock.argtypes = [HGLOBAL] + GlobalUnlock.restype = BOOL + + + @WINFUNCTYPE(c_long, HWND, UINT, WPARAM, LPARAM) + def WndProc(hwnd, message, wParam, lParam): + service = create_unicode_buffer(256) + topic = create_unicode_buffer(256) + if message == WM_DDE_INITIATE: + if ((lParam & 0xffff == 0 or (GlobalGetAtomName(lParam & 0xffff, service, 256) and service.value == appname)) and + (lParam >> 16 == 0 or (GlobalGetAtomName(lParam >> 16, topic, 256) and topic.value.lower() == 'system'))): + SendMessage(wParam, WM_DDE_ACK, hwnd, PackDDElParam(WM_DDE_ACK, GlobalAddAtom(appname), GlobalAddAtom('System'))) + return 0 + return DefWindowProc(hwnd, message, wParam, lParam) + + + class ProtocolHandler(GenericProtocolHandler): + + def __init__(self, master): + GenericProtocolHandler.__init__(self, master) + self.thread = threading.Thread(target=self.worker, name='DDE worker') + self.thread.daemon = True + self.thread.start() + + def close(self): + thread = self.thread + if thread: + self.thread = None + PostThreadMessage(thread.ident, WM_QUIT, 0, 0) + thread.join() # Wait for it to quit + + def worker(self): + wndclass = WNDCLASS() + wndclass.style = 0 + wndclass.lpfnWndProc = WndProc + wndclass.cbClsExtra = 0 + wndclass.cbWndExtra = 0 + wndclass.hInstance = windll.kernel32.GetModuleHandleW(0) + wndclass.hIcon = None + wndclass.hCursor = None + wndclass.hbrBackground = None + wndclass.lpszMenuName = None + wndclass.lpszClassName = 'DDEServer' + + if RegisterClass(byref(wndclass)): + hwnd = CreateWindowEx(0, + wndclass.lpszClassName, + "DDE Server", + 0, + CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, + self.master.winfo_id(), # Don't use HWND_MESSAGE since the window won't get DDE broadcasts + None, + wndclass.hInstance, + None) + msg = MSG() + while GetMessage(byref(msg), None, 0, 0) != 0: + if msg.message == WM_DDE_EXECUTE: + args = wstring_at(GlobalLock(msg.lParam)).strip() + GlobalUnlock(msg.lParam) + if args.lower().startswith('open("') and args.endswith('")'): + self.event(urllib2.unquote(args[6:-2]).strip()) + PostMessage(msg.wParam, WM_DDE_ACK, hwnd, PackDDElParam(WM_DDE_ACK, 0x80, msg.lParam)) + else: + PostMessage(msg.wParam, WM_DDE_ACK, hwnd, PackDDElParam(WM_DDE_ACK, 0, msg.lParam)) + elif msg.message == WM_DDE_TERMINATE: + PostMessage(msg.wParam, WM_DDE_TERMINATE, hwnd, 0) + else: + TranslateMessage(byref(msg)) + DispatchMessage(byref(msg)) + else: + print 'Failed to register DDE for cAPI' + +else: # Linux + + class ProtocolHandler(GenericProtocolHandler): + pass + diff --git a/setup.py b/setup.py index 742a5a38..bb089158 100755 --- a/setup.py +++ b/setup.py @@ -86,7 +86,15 @@ if sys.platform=='darwin': 'CFBundleLocalizations': sorted(set([x[:-len('.lproj')] for x in os.listdir(join(SPARKLE, 'Resources')) if x.endswith('.lproj')]) | set([x[:-len('.strings')] for x in os.listdir('L10n') if x.endswith('.strings')])), # https://github.com/sparkle-project/Sparkle/issues/238 'CFBundleShortVersionString': VERSION, 'CFBundleVersion': VERSION, + 'CFBundleURLTypes': [ + { + 'CFBundleTypeRole': 'Viewer', + 'CFBundleURLName': 'uk.org.marginal.%s.URLScheme' % APPNAME.lower(), + 'CFBundleURLSchemes': ['edmc'], + } + ], 'LSMinimumSystemVersion': '10.10', + 'NSAppleScriptEnabled': True, 'NSHumanReadableCopyright': u'© 2015-2018 Jonathan Harris', 'SUEnableAutomaticChecks': True, 'SUShowReleaseNotes': True, diff --git a/stats.py b/stats.py index eddacf2a..05821940 100644 --- a/stats.py +++ b/stats.py @@ -177,7 +177,6 @@ class StatsDialog(): def __init__(self, app): self.parent = app.w self.status = app.status - self.verify = app.verify self.showstats() def showstats(self): @@ -189,8 +188,6 @@ class StatsDialog(): try: data = companion.session.profile() - except companion.VerificationRequired: - return prefs.AuthenticationDialog(self.parent, partial(self.verify, self.showstats)) except companion.ServerError as e: self.status['text'] = str(e) return