diff --git a/.gitignore b/.gitignore index e2505dea..187b1a1a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,12 @@ build dist.* *.bak +*.json *.pyc *.pyo +*.dll +*.pdb *.msi *.wixobj +*.xml *.zip diff --git a/EDMarketConnector.manifest b/EDMarketConnector.manifest new file mode 100644 index 00000000..b8bb6efe --- /dev/null +++ b/EDMarketConnector.manifest @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/EDMarketConnector.py b/EDMarketConnector.py index f094e6c9..1a555f5e 100755 --- a/EDMarketConnector.py +++ b/EDMarketConnector.py @@ -66,8 +66,15 @@ class AppWindow: child.grid_configure(padx=5, pady=(platform=='darwin' and 3 or 2)) menubar = tk.Menu() - self.w['menu'] = menubar if platform=='darwin': + from Foundation import NSBundle + # https://www.tcl.tk/man/tcl/TkCmd/menu.htm + apple_menu = tk.Menu(menubar, name='apple') + apple_menu.add_command(label="About %s" % applongname, command=lambda:root.call('tk::mac::standardAboutPanel')) + apple_menu.add_command(label="Check for Update", command=lambda:self.updater.checkForUpdates()) + menubar.add_cascade(menu=apple_menu) + window_menu = tk.Menu(menubar, name='window') + menubar.add_cascade(menu=window_menu) # 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) @@ -76,10 +83,12 @@ class AppWindow: 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="Check for Update", command=lambda:self.updater.checkForUpdates()) 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) + self.w['menu'] = menubar # update geometry if config.get('geometry'): @@ -97,6 +106,12 @@ class AppWindow: else: self.login() + # Load updater after UI creation (for WinSparkle) + import update + self.updater = update.Updater(master) + master.bind_all('<>', self.onexit) # user-generated + + # call after credentials have changed def login(self): self.status['text'] = 'Logging in...' @@ -190,7 +205,7 @@ class AppWindow: self.status['text'] = status self.w.update_idletasks() - def onexit(self): + def onexit(self, event=None): config.set('geometry', '+{1}+{2}'.format(*self.w.geometry().split('+'))) config.close() self.session.close() diff --git a/EDMarketConnector.wxs b/EDMarketConnector.wxs index ca19c7f1..398e8a5f 100644 --- a/EDMarketConnector.wxs +++ b/EDMarketConnector.wxs @@ -17,6 +17,9 @@ Description="$(var.PRODUCTLONGNAME) installer" InstallerVersion="300" Compressed="yes" /> + + + @@ -87,6 +90,9 @@ + + + @@ -314,6 +320,7 @@ + diff --git a/README.md b/README.md index a77671c1..50baeb18 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,20 @@ Running from source & packaging -------- The application requires Python 2.7, plus the "requests" module. Run with `python EDMarketConnector.py`. -To package up for distribution requires py2app 0.9.x on Mac, and py2exe 0.6.x plus the [WiX Toolset](http://wixtoolset.org/) on Windows. Run `setup.py py2app` or `setup.py py2exe`. +To package up for distribution: + +Mac: + +* requires py2app 0.9.x +* [Sparkle.framework](https://github.com/sparkle-project/Sparkle) installed in /Library/Frameworks +* Run `setup.py py2app` + +Windows: + +* requires py2exe 0.6.x +* winsparkle.dll & .pdb from [WinSparkle](https://github.com/vslavik/winsparkle) copied to the current directory +* [WiX Toolset](http://wixtoolset.org/) +* Run `setup.py py2exe` Acknowledgements diff --git a/config.py b/config.py index 1aaf20d7..c14cda9c 100644 --- a/config.py +++ b/config.py @@ -131,6 +131,16 @@ class Config: SHDeleteKey(oldkey, '') RegCloseKey(oldkey) + # set WinSparkle defaults - https://github.com/vslavik/winsparkle/wiki/Registry-Settings + sparklekey = HKEY() + if not RegCreateKeyEx(self.hkey, 'WinSparkle', 0, None, 0, KEY_ALL_ACCESS, None, ctypes.byref(sparklekey), ctypes.byref(disposition)): + if disposition.value == REG_CREATED_NEW_KEY: + buf = ctypes.create_unicode_buffer('1') + RegSetValueEx(sparklekey, 'CheckForUpdates', 0, 1, buf, len(buf)*2) + buf = ctypes.create_unicode_buffer(unicode(47*60*60)) + RegSetValueEx(sparklekey, 'UpdateInterval', 0, 1, buf, len(buf)*2) + RegCloseKey(sparklekey) + if not self.get('outdir') or not isdir(self.get('outdir')): ctypes.windll.shell32.SHGetSpecialFolderPathW(0, buf, CSIDL_PERSONAL, 0) self.set('outdir', buf.value) diff --git a/setup.py b/setup.py index ed4bdda3..1eab8726 100755 --- a/setup.py +++ b/setup.py @@ -54,17 +54,23 @@ SHORTVERSION = ''.join(VERSION.split('.')[:3]) PY2APP_OPTIONS = {'dist_dir': dist_dir, 'optimize': 2, 'packages': [ 'requests' ], + 'frameworks': [ 'Sparkle.framework' ], 'excludes': [ 'PIL' ], 'iconfile': '%s.icns' % APPNAME, 'semi_standalone': True, 'site_packages': False, 'plist': { - 'CFBundleName': APPNAME, + 'CFBundleName': APPLONGNAME, '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', + 'SUEnableAutomaticChecks': True, + 'SUShowReleaseNotes': True, + 'SUAllowsAutomaticUpdates': False, + 'SUFeedURL': 'http://marginal.org.uk/edmarketconnector.xml', + 'SUScheduledCheckInterval': 47*60*60, }, 'graph': True, # output dependency graph in dist } @@ -78,6 +84,7 @@ PY2EXE_OPTIONS = {'dist_dir': dist_dir, if sys.platform=='win32': import requests DATA_FILES = [ ('', [requests.certs.where(), + 'WinSparkle.dll', '%s.ico' % APPNAME ] ) ] else: DATA_FILES = [ ] @@ -89,6 +96,9 @@ setup( windows = [ {'script': APP, 'icon_resources': [(0, '%s.ico' % APPNAME)], 'copyright': u'© 2015 Jonathan Harris', + 'name': APPNAME, # WinSparkle + 'company_name': 'Marginal', # WinSparkle + 'other_resources': [(24, 1, open(APPNAME+'.manifest').read())], } ], data_files = DATA_FILES, options = { 'py2app': PY2APP_OPTIONS, @@ -98,22 +108,39 @@ setup( if sys.platform == 'darwin': - if isdir('%s/%s.app' % (dist_dir, APPNAME)): + if isdir('%s/%s.app' % (dist_dir, APPLONGNAME)): # from CFBundleName + os.rename('%s/%s.app' % (dist_dir, APPLONGNAME), '%s/%s.app' % (dist_dir, APPNAME)) 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, SHORTVERSION)) + PKG = '%s_mac_%s.zip' % (APPNAME, SHORTVERSION) + os.system('cd %s; ditto -ck --keepParent --sequesterRsrc %s.app ../%s; cd ..' % (dist_dir, APPNAME, PKG)) 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)) + shutil.copy('WinSparkle.pdb', dist_dir) # For debugging - not included in package os.system(r'"C:\Program Files (x86)\WiX Toolset v3.9\bin\candle.exe" -out %s\ %s.wxs' % (dist_dir, APPNAME)) if exists('%s/%s.wixobj' % (dist_dir, APPNAME)): - os.system(r'"C:\Program Files (x86)\WiX Toolset v3.9\bin\light.exe" -sacl -spdb -sw1076 %s\%s.wixobj -out %s_win_%s.msi' % (dist_dir, APPNAME, APPNAME, SHORTVERSION)) + PKG = '%s_win_%s.msi' % (APPNAME, SHORTVERSION) + os.system(r'"C:\Program Files (x86)\WiX Toolset v3.9\bin\light.exe" -sacl -spdb -sw1076 %s\%s.wixobj -out %s' % (dist_dir, APPNAME, PKG)) + +# Make appcast entry +appcast = open('appcast_%s_%s.xml' % (sys.platform=='darwin' and 'mac' or 'win', SHORTVERSION), 'w') +appcast.write(''' +\t\t +\t\t\tRelease {0} +\t\t\t +\t\t\t\tRelease {0} +
    + +
+\t\t\t\t]]> +\t\t\t
+\t\t\t +\t\t
+'''.format(float(SHORTVERSION)/100, SHORTVERSION, PKG, sys.platform=='darwin' and 'osx' or 'windows"\n\t\t\t\tsparkle:installerArguments="/passive', VERSION, os.stat(PKG).st_size)) diff --git a/update.py b/update.py new file mode 100644 index 00000000..d1d7b2f0 --- /dev/null +++ b/update.py @@ -0,0 +1,97 @@ +import os +from os.path import dirname, join +import sys + + +# ensure registry is set up on Windows before we start +import config + +class NullUpdater: + + def __init__(self, master): + pass + + def checkForUpdates(self): + pass + + def close(self): + pass + + +if not getattr(sys, 'frozen', False): + + class Updater(NullUpdater): + pass + +elif sys.platform=='darwin': + + import objc + + class Updater(NullUpdater): + + # https://github.com/sparkle-project/Sparkle/wiki/Customization + + def __init__(self, master): + try: + objc.loadBundle('Sparkle', globals(), join(dirname(sys.executable), os.pardir, 'Frameworks', 'Sparkle.framework')) + self.updater = SUUpdater.sharedUpdater() + except: + # can't load framework - not frozen or not included in app bundle? + self.updater = None + + def checkForUpdates(self): + if self.updater: + self.updater.checkForUpdates_(None) + + def close(): + self.updater = None + + +elif sys.platform=='win32': + + import ctypes + + # https://github.com/vslavik/winsparkle/blob/master/include/winsparkle.h#L272 + root = None + + def shutdown_request(): + root.event_generate('<>', when="tail") + + class Updater(NullUpdater): + + # https://github.com/vslavik/winsparkle/wiki/Basic-Setup + + def __init__(self, master): + try: + sys.frozen # don't want to try updating python.exe + self.updater = ctypes.cdll.WinSparkle + self.updater.win_sparkle_set_appcast_url('http://marginal.org.uk/edmarketconnector.xml') # py2exe won't let us embed this in resources + + # set up shutdown callback + global root + root = master + self.callback_t = ctypes.CFUNCTYPE(None) # keep reference + self.callback_fn = self.callback_t(shutdown_request) + self.updater.win_sparkle_set_shutdown_request_callback(self.callback_fn) + + self.updater.win_sparkle_init() + + except: + from traceback import print_exc + print_exc() + self.updater = None + + def checkForUpdates(self): + if self.updater: + self.updater.win_sparkle_check_update_with_ui() + + def close(self): + if self.updater: + updater.win_sparkle_cleanup() + self.updater = None + +else: + + class Updater(NullUpdater): + pass +