diff --git a/.gitignore b/.gitignore index 27aafabc..e2505dea 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ .DS_Store build -dist +dist.* *.bak *.pyc *.pyo +*.msi +*.wixobj +*.zip diff --git a/EDMarketConnector.icns b/EDMarketConnector.icns new file mode 100644 index 00000000..bdc87423 Binary files /dev/null and b/EDMarketConnector.icns differ diff --git a/EDMarketConnector.ico b/EDMarketConnector.ico new file mode 100644 index 00000000..6676df10 Binary files /dev/null and b/EDMarketConnector.ico differ diff --git a/EDMarketConnector.py b/EDMarketConnector.py new file mode 100755 index 00000000..b3c87175 --- /dev/null +++ b/EDMarketConnector.py @@ -0,0 +1,186 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +from sys import platform +from os import mkdir +from os.path import expanduser, isdir, join +from time import time, localtime, strftime + +import Tkinter as tk +import ttk + +if __debug__: + from traceback import print_exc + +import companion +import bpc +import eddn +import prefs +from config import applongname, config + + +class AppWindow: + + def __init__(self, master): + + self.holdofftime = (config.read('querytime') or 0) + companion.holdoff + self.session = companion.Session() + + self.w = master + self.w.title(applongname) + self.w.rowconfigure(0, weight=1) + self.w.columnconfigure(0, weight=1) + + frame = ttk.Frame(self.w) + frame.grid(sticky=tk.NSEW) + frame.columnconfigure(1, weight=1) + frame.rowconfigure(4, weight=1) + + ttk.Label(frame, text="Cmdr:").grid(row=0, column=0, sticky=tk.W) + ttk.Label(frame, text="System:").grid(row=1, column=0, sticky=tk.W) + ttk.Label(frame, text="Station:").grid(row=2, column=0, sticky=tk.W) + + self.cmdr = ttk.Label(frame, width=-20) + self.system = ttk.Label(frame, width=-20) + self.station = ttk.Label(frame, width=-20) + self.button = ttk.Button(frame, text='Update', command=self.getandsend, default=tk.ACTIVE, state=tk.DISABLED) + self.status = ttk.Label(frame, width=-25) + self.w.bind('', self.getandsend) + + self.cmdr.grid(row=0, column=1, sticky=tk.W) + self.system.grid(row=1, column=1, sticky=tk.W) + self.station.grid(row=2, column=1, sticky=tk.W) + self.button.grid(row=3, column=0, columnspan=2, sticky=tk.NSEW) + self.status.grid(row=4, column=0, columnspan=2, sticky=tk.SW) + + for child in frame.winfo_children(): + child.grid_configure(padx=5, pady=(platform=='darwin' and 3 or 2)) + + menubar = tk.Menu() + self.w['menu'] = menubar + if platform=='darwin': + # https://www.tcl.tk/man/tcl/TkCmd/tk_mac.htm + root.createcommand('tkAboutDialog', lambda:root.call('tk::mac::standardAboutPanel')) + root.createcommand("::tk::mac::Quit", self.onexit) + root.createcommand("::tk::mac::ShowPreferences", lambda:prefs.PreferencesDialog(self.w, self.login)) + root.createcommand("::tk::mac::ReopenApplication", self.w.deiconify) # click on app in dock = restore + root.protocol("WM_DELETE_WINDOW", self.w.withdraw) # close button shouldn't quit app + else: + file_menu = tk.Menu(menubar, tearoff=tk.FALSE) + file_menu.add_command(label="Settings", command=lambda:prefs.PreferencesDialog(self.w, self.login)) + file_menu.add_command(label="Exit", command=self.onexit) + menubar.add_cascade(label="File", menu=file_menu) + root.protocol("WM_DELETE_WINDOW", self.onexit) + + if platform=='win32': + self.w.wm_iconbitmap(default='EDMarketConnector.ico') + + # update geometry + if config.read('geometry'): + self.w.geometry(config.read('geometry')) + self.w.update_idletasks() + self.w.wait_visibility() + (w, h) = (self.w.winfo_width(), self.w.winfo_height()) + self.w.minsize(w, h) # Minimum size = initial size + self.w.maxsize(-1, h) # Maximum height = initial height + + # First run + if not config.read('username') or not config.read('password'): + prefs.PreferencesDialog(self.w, self.login) + else: + self.login() + + # call after credentials have changed + def login(self): + self.status['text'] = 'Logging in...' + self.button['state'] = tk.DISABLED + self.w.update_idletasks() + try: + self.session.login(config.read('username'), config.read('password')) + self.status['text'] = '' + except companion.VerificationRequired: + # don't worry about authentication now + self.status['text'] = '' + except Exception as e: + self.status['text'] = str(e) + self.cooldown() + + # callback after verification code + def verify(self, code): + try: + self.session.verify(code) + except Exception as e: + if __debug__: print_exc() + self.status['text'] = str(e) + else: + return self.getandsend() # try again + + def getandsend(self, event=None): + if time() < self.holdofftime: return # Was invoked by Return key while in cooldown + + self.cmdr['text'] = self.system['text'] = self.station['text'] = '' + self.status['text'] = 'Fetching market data...' + self.button['state'] = tk.DISABLED + self.w.update_idletasks() + + try: + querytime = int(time()) + + data = self.session.query() + + self.cmdr['text'] = data.get('commander') and data.get('commander').get('name') or '' + self.system['text'] = data.get('lastSystem') and data.get('lastSystem').get('name') or '' + self.station['text'] = data.get('commander') and data.get('commander').get('docked') and data.get('lastStarport') and data.get('lastStarport').get('name') or '-' + + config.write('querytime', querytime) + self.holdofftime = querytime + companion.holdoff + + if not data.get('commander') or not data.get('commander').get('docked'): + raise Exception("You're not docked at a station!") + elif not data.get('lastStarport') or not data.get('lastStarport').get('commodities'): + raise Exception("Station doesn't have a market!") + + if config.read('output') & config.OUT_BPC: + bpc.export(data) + + if config.read('output') & config.OUT_EDDN: + eddn.export(data, self.setstatus) + + except companion.VerificationRequired: + return prefs.AuthenticationDialog(self.w, self.verify) + + except Exception as e: + if __debug__: print_exc() + self.status['text'] = str(e) + + else: + self.status['text'] = strftime('Last updated at %H:%M:%S', localtime(querytime)) + + self.cooldown() + + def cooldown(self): + if time() < self.holdofftime: + self.button['text'] = 'cool down %ds' % (self.holdofftime - time()) + self.w.after(1000, self.cooldown) + else: + self.button['text'] = 'Update' + self.button['state'] = tk.NORMAL + + # callback to update status text + def setstatus(self, status): + self.status['text'] = status + self.w.update_idletasks() + + def onexit(self): + config.write('geometry', '+{1}+{2}'.format(*self.w.geometry().split('+'))) + config.close() + self.session.close() + self.w.destroy() + + +if __name__ == "__main__": + + # Run the app + root = tk.Tk() + app = AppWindow(root) + root.mainloop() diff --git a/EDMarketConnector.wxs b/EDMarketConnector.wxs new file mode 100644 index 00000000..ca19c7f1 --- /dev/null +++ b/EDMarketConnector.wxs @@ -0,0 +1,384 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/EDMarketConnector.xcf b/EDMarketConnector.xcf new file mode 100644 index 00000000..fb4b99fa Binary files /dev/null and b/EDMarketConnector.xcf differ diff --git a/bpc.py b/bpc.py new file mode 100644 index 00000000..fcdcdc1a --- /dev/null +++ b/bpc.py @@ -0,0 +1,56 @@ +# Export in Slopey's BPC format +# -*- coding: utf-8 -*- + +from os.path import join +import codecs +import datetime +import hashlib +import time + +from config import config + +commoditymap = { 'Agricultural Medicines': 'Agri-Medicines', + 'Atmospheric Extractors': 'Atmospheric Processors', + 'Auto Fabricators': 'Auto-Fabricators', + 'Basic Narcotics': 'Narcotics', + 'Bio Reducing Lichen': 'Bioreducing Lichen', + 'Hazardous Environment Suits': 'H.E. Suits', + 'Heliostatic Furnaces': 'Microbial Furnaces', + 'Marine Supplies': 'Marine Equipment', + 'Non Lethal Weapons': 'Non-Lethal Weapons', + 'Terrain Enrichment Systems': 'Land Enrichment Systems' } + +bracketmap = { 0: '', + 1: 'Low', + 2: 'Med', + 3: 'High' } + + +def export(data): + + querytime = config.read('querytime') or int(time.time()) + + filename = join(config.read('outdir'), '%s.%s.%s.bpc' % (data.get('lastSystem').get('name').strip(), data.get('lastStarport').get('name').strip(), time.strftime('%Y-%m-%dT%H.%M.%S', time.localtime(querytime)))) + + timestamp = datetime.datetime.utcfromtimestamp(querytime).isoformat() + rowheader = '%s;%s;%s' % (data.get('commander').get('name').replace(';',':'), data.get('lastSystem').get('name').strip(), data.get('lastStarport').get('name').strip()) + + h = codecs.open(filename, 'w', 'utf-8') + h.write('userID;System;Station;Commodity;Sell;Buy;Demand;;Supply;;Date;\r\n') + + for commodity in data.get('lastStarport').get('commodities'): + if commodity.get('categoryname') and commodity.get('categoryname') != 'NonMarketable': + h.write('%s;%s;%s;%s;%s;%s;%s;%s;%s;\r\n' % ( + rowheader, + commoditymap.get(commodity.get('name').strip(), commodity.get('name').strip()), + commodity.get('sellPrice') and int(commodity.get('sellPrice')) or '', + commodity.get('buyPrice') and int(commodity.get('buyPrice')) or '', + commodity.get('demandBracket') and int(commodity.get('demand')) or '', + bracketmap.get(commodity.get('demandBracket'), ''), + commodity.get('stockBracket') and int(commodity.get('stock')) or '', + bracketmap.get(commodity.get('stockBracket'), ''), + timestamp)) + elif __debug__: + print 'Skipping %s : %s' % (commodity.get('name'), commodity.get('categoryname')) + + h.close() diff --git a/companion.py b/companion.py new file mode 100644 index 00000000..87ecd3d9 --- /dev/null +++ b/companion.py @@ -0,0 +1,106 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +import json +import requests +from collections import defaultdict +from cookielib import LWPCookieJar +import os +from os.path import dirname, join +from requests.packages import urllib3 +import sys +from sys import platform + +if __debug__: + from traceback import print_exc + +from config import config + +holdoff = 120 # be nice + + +class CredentialsError(Exception): + def __str__(self): + return 'Error: Invalid Credentials' + +class VerificationRequired(Exception): + def __str__(self): + return 'Authentication required' + +# 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) + + def __init__(self): + self.state = Session.STATE_INIT + self.credentials = None + + urllib3.disable_warnings() # yuck suppress InsecurePlatformWarning + 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): + self.state = Session.STATE_INIT + if (not username or not password): + if not self.credentials: + raise CredentialsError() + else: + self.credentials = { 'email' : username, 'password' : password } + r = self.session.post('https://companion.orerve.net/user/login', data = self.credentials) + r.raise_for_status() + + if 'Password' in r.text: + raise CredentialsError() + elif 'Verification Code' in r.text: + self.state = Session.STATE_AUTH + raise VerificationRequired() + else: + self.state = Session.STATE_OK + return r.status_code + + def verify(self, code): + r = self.session.post('https://companion.orerve.net/user/confirm', + data = { 'code' : code }) + r.raise_for_status() + # verification doesn't actually return a yes/no, so log in again to determine state + try: + self.login() + except: + pass + + def query(self): + if self.state == Session.STATE_NONE: + raise Exception('General error') # Shouldn't happen + elif self.state == Session.STATE_INIT: + raise CredentialsError() + elif self.state == Session.STATE_AUTH: + raise VerificationRequired() + r = self.session.get('https://companion.orerve.net/profile') + + if r.status_code == requests.codes.forbidden: + # Maybe our session cookie expired? + self.login() + r = self.session.get('https://companion.orerve.net/profile') + + r.raise_for_status() + return json.loads(r.text) + + def close(self): + self.state = Session.STATE_NONE + try: + self.session.cookies.save() + self.session.close() + except: + pass + self.session = None diff --git a/config.py b/config.py new file mode 100644 index 00000000..a740cb06 --- /dev/null +++ b/config.py @@ -0,0 +1,94 @@ +import sys +from os import mkdir +from os.path import basename, isdir, join +from sys import platform + +if platform=='win32': + import numbers + import _winreg + + +appname = 'EDMarketConnector' +applongname = 'E:D Market Connector' +appversion = '1.0.0.0' + + +class Config: + + OUT_EDDN = 1 + OUT_BPC = 2 + + if platform=='darwin': + + def __init__(self): + from Foundation import NSBundle, NSUserDefaults, NSSearchPathForDirectoriesInDomains, NSApplicationSupportDirectory, NSDocumentDirectory, NSLibraryDirectory, NSUserDomainMask + + self.app_dir = join(NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, True)[0], appname) + if not isdir(self.app_dir): + mkdir(self.app_dir) + + self.bundle = getattr(sys, 'frozen', False) and NSBundle.mainBundle().bundleIdentifier() or 'uk.org.marginal.%s' % appname.lower() # Don't use Python's settings if interactive + self.defaults = NSUserDefaults.standardUserDefaults() + settings = self.defaults.persistentDomainForName_(self.bundle) or {} + self.settings = dict(settings) + + # Check out_dir exists + if not self.read('outdir') or not isdir(self.read('outdir')): + self.write('outdir', NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, True)[0]) + + def read(self, key): + return self.settings.get(key) + + def write(self, key, val): + self.settings[key] = val + + def close(self): + self.defaults.setPersistentDomain_forName_(self.settings, self.bundle) + self.defaults.synchronize() + self.defaults = None + + elif platform=='win32': + + def __init__(self): + import ctypes.wintypes + CSIDL_PERSONAL = 0x0005 + CSIDL_LOCAL_APPDATA = 0x001C + buf = ctypes.create_unicode_buffer(ctypes.wintypes.MAX_PATH) + ctypes.windll.shell32.SHGetSpecialFolderPathW(0, buf, CSIDL_LOCAL_APPDATA, 0) + self.app_dir = join(buf.value, appname) + if not isdir(self.app_dir): + mkdir(self.app_dir) + + self.handle = _winreg.CreateKey(_winreg.HKEY_CURRENT_USER, r'Software\%s' % appname) + try: + if not isdir(_winreg.QueryValue(self.handle, 'outdir')): + raise Exception() + except: + ctypes.windll.shell32.SHGetSpecialFolderPathW(0, buf, CSIDL_PERSONAL, 0) + _winreg.SetValueEx(self.handle, 'outdir', 0, _winreg.REG_SZ, buf.value) + + def read(self, key): + try: + return _winreg.QueryValueEx(self.handle, key)[0] + except: + return None + + def write(self, key, val): + if isinstance(val, basestring): + _winreg.SetValueEx(self.handle, key, 0, _winreg.REG_SZ, val) + elif isinstance(val, numbers.Integral): + _winreg.SetValueEx(self.handle, key, 0, _winreg.REG_DWORD, val) + else: + raise NotImplementedError() + + def close(self): + _winreg.CloseKey(self.handle) + self.handle = None + + else: # unix + + def __init__(self): + raise NotImplementedError('Implement me') + +# singleton +config = Config() diff --git a/eddn.py b/eddn.py new file mode 100644 index 00000000..cc512cab --- /dev/null +++ b/eddn.py @@ -0,0 +1,62 @@ +# Export to EDDN +# -*- coding: utf-8 -*- + +import datetime +import hashlib +import json +import requests +from platform import system +from sys import platform +import time + +from config import applongname, appversion, config +from bpc import commoditymap, bracketmap + +upload = 'http://eddn-gateway.elite-markets.net:8080/upload/' +schema = 'http://schemas.elite-markets.net/eddn/commodity/1' + +def export(data, callback): + + callback('Sending data to EDDN...') + + header = { 'softwareName': '%s [%s]' % (applongname, platform=='darwin' and "Mac OS" or system()), + 'softwareVersion': appversion, + 'uploaderID': data.get('commander').get('name') } # was hashlib.md5(config.read('username')).hexdigest() } + systemName = data.get('lastSystem').get('name').strip() + stationName = data.get('lastStarport').get('name').strip() + timestamp = datetime.datetime.utcfromtimestamp(config.read('querytime') or int(time.time())).isoformat() + + # route all requests through a session in the hope of using keep-alive + session = requests.Session() + session.headers['connection'] = 'keep-alive' # can help through a proxy? + + commodities = data.get('lastStarport').get('commodities') + i=0 + for commodity in commodities: + i = i+1 + callback('Sending %d/%d' % (i, len(commodities))) + if commodity.get('categoryname') and commodity.get('categoryname') != 'NonMarketable': + msg = { '$schemaRef': schema, + 'header': header, + 'message': { + 'systemName': systemName, + 'stationName': stationName, + 'itemName': commoditymap.get(commodity.get('name').strip(), commodity.get('name').strip()), + 'buyPrice': int(commodity.get('buyPrice')), + 'stationStock': int(commodity.get('stock')), + 'sellPrice': int(commodity.get('sellPrice')), + 'demand': int(commodity.get('demand')), + 'timestamp': timestamp, + } + } + if commodity.get('stockBracket'): + msg['message']['supplyLevel'] = bracketmap.get(commodity.get('stockBracket')) + if commodity.get('demandBracket'): + msg['message']['demandLevel'] = bracketmap.get(commodity.get('demandBracket')) + + r = requests.post(upload, data=json.dumps(msg), verify=True) + + elif __debug__: + print 'Skipping %s : %s' % (commodity.get('name'), commodity.get('categoryname')) + + session.close() diff --git a/prefs.py b/prefs.py new file mode 100644 index 00000000..56221b9f --- /dev/null +++ b/prefs.py @@ -0,0 +1,166 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +from os.path import dirname, isdir, sep +from sys import platform + +import Tkinter as tk +import ttk +import tkFileDialog + +from config import config + + +class PreferencesDialog(tk.Toplevel): + + def __init__(self, parent, callback): + tk.Toplevel.__init__(self, parent) + + self.parent = parent + self.callback = callback + self.title(platform=='darwin' and 'Preferences' or 'Settings') + + if parent.winfo_viewable(): + self.transient(parent) + + # position over parent + self.geometry("+%d+%d" % (parent.winfo_rootx(), parent.winfo_rooty())) + + # remove decoration + self.resizable(tk.FALSE, tk.FALSE) + if platform=='win32': + self.attributes('-toolwindow', tk.TRUE) + elif platform=='darwin': + # http://wiki.tcl.tk/13428 + parent.call('tk::unsupported::MacWindowStyle', 'style', self, 'utility') + + frame = ttk.Frame(self) + frame.grid(sticky=tk.NSEW) + + credframe = ttk.LabelFrame(frame, text='Credentials') + credframe.grid(padx=10, pady=10, sticky=tk.NSEW) + credframe.columnconfigure(1, weight=1) + + ttk.Label(credframe, text="Please log in with your Elite:Dangerous account details.").grid(row=0, columnspan=2, sticky=tk.W) + ttk.Label(credframe, text="Username (Email)").grid(row=1, sticky=tk.W) + ttk.Label(credframe, text="Password").grid(row=2, sticky=tk.W) + + self.username = ttk.Entry(credframe) + self.username.insert(0, config.read('username') or '') + self.username.grid(row=1, column=1, sticky=tk.NSEW) + self.username.focus_set() + self.password = ttk.Entry(credframe, show=u'•') + self.password.insert(0, config.read('password') or '') + self.password.grid(row=2, column=1, sticky=tk.NSEW) + + outframe = ttk.LabelFrame(frame, text='Output') + outframe.grid(padx=10, pady=10, sticky=tk.NSEW) + outframe.columnconfigure(1, weight=1) + + self.outvar = tk.IntVar() + self.outvar.set(config.read('output') or config.OUT_EDDN) + ttk.Label(outframe, text="Please choose where you want the market data saved.").grid(row=0, columnspan=3, sticky=tk.W) + ttk.Radiobutton(outframe, text="Online to the Elite Dangerous Data Network (EDDN)", variable=self.outvar, value=config.OUT_EDDN, command=self.outvarchanged).grid(row=1, columnspan=3, sticky=tk.W) + ttk.Radiobutton(outframe, text="Offline to Slopey's BPC files in folder:", variable=self.outvar, value=config.OUT_BPC, command=self.outvarchanged).grid(row=2, columnspan=3, sticky=tk.W) + ttk.Label(outframe, width=-1).grid(row=3, column=0) + self.outbutton = ttk.Button(outframe, text=(platform=='darwin' and 'Browse...' or 'Choose...'), command=self.outbrowse) + self.outbutton.grid(row=2, column=2, sticky=tk.E) + self.outdir = ttk.Entry(outframe) + self.outdir.insert(0, config.read('outdir')) + self.outdir.grid(row=3, column=1, columnspan=2, sticky=tk.NSEW) + self.outvarchanged() + + for child in credframe.winfo_children() + outframe.winfo_children(): + child.grid_configure(padx=5, pady=3) + + if platform=='darwin': + self.protocol("WM_DELETE_WINDOW", self.apply) # close button applies changes + else: + buttonframe = ttk.Frame(frame) + buttonframe.grid(padx=10, pady=10, sticky=tk.NSEW) + buttonframe.columnconfigure(0, weight=1) + ttk.Label(buttonframe).grid(row=0, column=0) # spacer + ttk.Button(buttonframe, text='OK', command=self.apply).grid(row=0, column=1, sticky=tk.E) + + # wait for window to appear on screen before calling grab_set + self.wait_visibility() + self.grab_set() + #self.wait_window(self) # causes duplicate events on OSX + + def outvarchanged(self): + self.outbutton['state'] = self.outvar.get()>config.OUT_EDDN and tk.NORMAL or tk.DISABLED + self.outdir['state'] = self.outvar.get()>config.OUT_EDDN and 'readonly' or tk.DISABLED + + def outbrowse(self): + d = tkFileDialog.askdirectory(parent=self, initialdir=self.outdir.get(), title='Output folder', mustexist=tk.TRUE) + if d: + self.outdir['state'] = tk.NORMAL # must be writable to update + self.outdir.delete(0, tk.END) + self.outdir.insert(0, d.replace('/', sep)) + self.outdir['state'] = 'readonly' + + def apply(self): + credentials = (config.read('username'), config.read('password')) + config.write('username', self.username.get().strip()) + config.write('password', self.password.get().strip()) + config.write('output', self.outvar.get()) + config.write('outdir', self.outdir.get().strip()) + self.destroy() + if credentials != (config.read('username'), config.read('password')) and self.callback: + self.callback() + + +class AuthenticationDialog(tk.Toplevel): + + def __init__(self, parent, callback): + tk.Toplevel.__init__(self, parent) + + self.parent = parent + self.callback = callback + self.title('Authentication') + + if parent.winfo_viewable(): + self.transient(parent) + + # position over parent + self.geometry("+%d+%d" % (parent.winfo_rootx(), parent.winfo_rooty())) + + # remove decoration + self.resizable(tk.FALSE, tk.FALSE) + if platform=='win32': + self.attributes('-toolwindow', tk.TRUE) + elif platform=='darwin': + # http://wiki.tcl.tk/13428 + parent.call('tk::unsupported::MacWindowStyle', 'style', self, 'utility') + + frame = ttk.Frame(self) + frame.grid(sticky=tk.NSEW) + frame.columnconfigure(0, weight=3) + frame.columnconfigure(2, weight=1) + + ttk.Label(frame, text='A verification code has now been sent to the\nemail address associated with your Elite account.\nPlease enter the code into the box below.', anchor=tk.W, justify=tk.LEFT).grid(columnspan=4, sticky=tk.NSEW) + ttk.Label(frame).grid(row=1, column=0) # spacer + self.code = ttk.Entry(frame, width=8, validate='key', validatecommand=(self.register(self.validatecode), +'%P')) + self.code.grid(row=1, column=1) + self.code.focus_set() + ttk.Label(frame).grid(row=1, column=2) # spacer + self.button = ttk.Button(frame, text='OK', command=self.apply, state=tk.DISABLED) + self.button.grid(row=1, column=3, sticky=tk.E) + + for child in frame.winfo_children(): + child.grid_configure(padx=5, pady=3) + + # wait for window to appear on screen before calling grab_set + self.wait_visibility() + self.grab_set() + #self.wait_window(self) # causes duplicate events on OSX + + def validatecode(self, newval): + self.button['state'] = len(newval.strip())==5 and tk.NORMAL or tk.DISABLED + return True + + def apply(self): + code = self.code.get().strip() + self.destroy() + if self.callback: self.callback(code) diff --git a/setup.py b/setup.py new file mode 100755 index 00000000..a636d0e2 --- /dev/null +++ b/setup.py @@ -0,0 +1,119 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +""" +This is a setup.py script generated by py2applet + +Usage: + python setup.py py2app +""" + +from setuptools import setup +import os +from os.path import exists, isdir, join +import platform +import re +import shutil +import sys + +if sys.platform=='win32': + assert platform.architecture()[0]=='32bit', 'Assumes a Python built for 32bit' + import py2exe + dist_dir = 'dist.win32' +elif sys.platform=='darwin': + dist_dir = 'dist.macosx' +else: + assert False, 'Unsupported platform %s' % sys.platform + +if dist_dir and len(dist_dir)>1 and isdir(dist_dir): + shutil.rmtree(dist_dir) + +# "Developer ID Application" name for signing +macdeveloperid = None + + +# Patch py2app recipe enumerator to skip the sip recipe since it's too enthusiastic - we'll list additional Qt modules explicitly +if sys.platform=='darwin': + from py2app import recipes + import py2app.build_app + def iterRecipes(module=recipes): + for name in dir(module): + if name.startswith('_') or name=='sip': + continue + check = getattr(getattr(module, name), 'check', None) + if check is not None: + yield (name, check) + py2app.build_app.iterRecipes = iterRecipes + + +APP = 'EDMarketConnector.py' +APPNAME = re.search(r"^appname\s*=\s*'(.+)'", file('config.py').read(), re.MULTILINE).group(1) +APPLONGNAME = re.search(r"^applongname\s*=\s*'(.+)'", file('config.py').read(), re.MULTILINE).group(1) +VERSION = re.search(r"^appversion\s*=\s*'(.+)'", file('config.py').read(), re.MULTILINE).group(1) + +PY2APP_OPTIONS = {'dist_dir': dist_dir, + 'optimize': 2, + 'packages': [ 'requests' ], + 'iconfile': '%s.icns' % APPNAME, + 'semi_standalone': True, + 'site_packages': False, + 'plist': { + 'CFBundleName': APPNAME, + 'CFBundleIdentifier': 'uk.org.marginal.%s' % APPNAME.lower(), + 'CFBundleShortVersionString': VERSION, + 'CFBundleVersion': VERSION, + 'LSMinimumSystemVersion': '.'.join(platform.mac_ver()[0].split('.')[:2]), # minimum version = build version + 'NSHumanReadableCopyright': u'© 2015 Jonathan Harris', + }, + 'graph': True, # output dependency graph in dist + } + +PY2EXE_OPTIONS = {'dist_dir': dist_dir, + 'optimize': 2, + 'packages': [ 'requests', 'encodings.ascii','encodings.mbcs','encodings.latin_1','encodings.utf_8','encodings.utf_16','encodings.cp437' ], + #'ascii': True, # suppress other encodings + } + +if sys.platform=='win32': + import requests + DATA_FILES = [ ('', [requests.certs.where(), + '%s.ico' % APPNAME ] ) ] +else: + DATA_FILES = [ ] + +setup( + name = APPLONGNAME, + version = VERSION, + app = [APP], + windows = [ {'script': APP, + 'icon_resources': [(0, '%s.ico' % APPNAME)], + 'copyright': u'© 2015 Jonathan Harris', + #XXX 'other_resources': [(24, 1, manifest)], + } ], + data_files = DATA_FILES, + options = { 'py2app': PY2APP_OPTIONS, + 'py2exe': PY2EXE_OPTIONS }, + setup_requires = [sys.platform=='darwin' and 'py2app' or 'py2exe'], +) + + +if sys.platform == 'darwin': + if macdeveloperid: + os.system('codesign --deep -v -s "Developer ID Application: %s" %s/%s.app' % (macdeveloperid, dist_dir, APPNAME)) + # Make zip for distribution, preserving signature + os.system('cd %s; ditto -ck --keepParent --sequesterRsrc %s.app ../%s_mac_%s.zip; cd ..' % (dist_dir, APPNAME, APPNAME, VERSION)) +else: + # Manually trim the tcl/tk folders + os.unlink(join(dist_dir, 'w9xpopen.exe')) + for d in [ r'tcl\tcl8.5\encoding', + r'tcl\tcl8.5\http1.0', + r'tcl\tcl8.5\msgs', + r'tcl\tcl8.5\tzdata', + r'tcl\tk8.5\demos', + r'tcl\tk8.5\images', + r'tcl\tk8.5\msgs', ]: + shutil.rmtree(join(dist_dir, d)) + if exists('%s.wixobj' % APPNAME): + os.unlink('%s.wixobj' % APPNAME) + os.system(r'"C:\Program Files (x86)\WiX Toolset v3.9\bin\candle.exe" -ext WixUIExtension -ext WixUtilExtension %s.wxs' % APPNAME) + if exists('%s.wixobj' % APPNAME): + os.system(r'"C:\Program Files (x86)\WiX Toolset v3.9\bin\light.exe" -ext WixUIExtension -ext WixUtilExtension -b %s -sacl -spdb %s.wixobj -out %s_win_%s.msi' % (dist_dir, APPNAME, APPNAME, VERSION))