1
0
mirror of https://github.com/EDCD/EDMarketConnector.git synced 2025-04-21 11:27:38 +03:00

PKCE OAuth2 access to cAPI

This commit is contained in:
Jonathan Harris 2018-12-28 02:14:03 +00:00
parent 01b7f13148
commit f17f5d3f25
9 changed files with 393 additions and 262 deletions

17
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)

@ -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('<<JournalEvent>>', self.journal_event) # Journal monitoring
self.w.bind_all('<<DashboardEvent>>', self.dashboard_event) # Dashboard monitoring
self.w.bind_all('<<PluginError>>', self.plugin_error) # Statusbar
self.w.bind_all('<<CompanionAuthEvent>>', self.auth) # cAPI auth
self.w.bind_all('<<Quit>>', self.onexit) # Updater
# 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

@ -90,6 +90,30 @@
<Directory Id="TARGETDIR" Name="SourceDir">
<!-- http://wixtoolset.org/documentation/manual/v3/howtos/files_and_registry/write_a_registry_entry.html -->
<Component Id="RegistryEntries" Guid="*">
<RegistryKey Root="HKCR" Key="edmc">
<RegistryValue Type="string" Value=""/>
<RegistryValue Type="string" Name="URL Protocol" Value="$(var.PRODUCTLONGNAME)"/>
<RegistryKey Key="DefaultIcon">
<RegistryValue Type="string" Value="[INSTALLDIR]EDMarketConnector.exe,0"/>
</RegistryKey>
<RegistryKey Key="shell">
<RegistryKey Key="open">
<RegistryKey Key="command">
<RegistryValue Type="string" Value='"[INSTALLDIR]EDMarketConnector.exe" "%1"'/>
</RegistryKey>
<RegistryKey Key="ddeexec">
<RegistryValue Type="string" Value='Open("%1")'/>
<!--<RegistryKey Key="application">
<RegistryValue Type="string" Value="$(var.PRODUCTNAME)"/>
</RegistryKey> -->
</RegistryKey>
</RegistryKey>
</RegistryKey>
</RegistryKey>
</Component>
<!-- Generate with `heat.exe dir dist.win32 -gg -sfrag -suid -out foo.wxs` -->
<!-- Sadly too late for auto-generated Component UUIDs -->
@ -476,6 +500,7 @@
</Directory>
<Feature Id='Complete' Level='1'>
<ComponentRef Id="RegistryEntries" />
<ComponentRef Id="MainExecutable" />
<ComponentRef Id="_ctypes.pyd" />
<ComponentRef Id="_hashlib.pyd" />

@ -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";

@ -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()

@ -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)

194
protocol.py Normal file

@ -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('<<CompanionAuthEvent>>', 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

@ -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,

@ -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