diff --git a/freeze.py b/freeze.py index a7844ae..97e8618 100755 --- a/freeze.py +++ b/freeze.py @@ -19,11 +19,6 @@ BASE_DIR = os.path.split( os.path.abspath(__file__) )[ 0 ] MAIN_SCRIPT = "vasl_templates/main.py" APP_ICON = os.path.join( BASE_DIR, "vasl_templates/webapp/static/images/app.ico" ) -TARGET_NAMES = { - "win32": "vasl-templates.exe", -} -DEFAULT_TARGET_NAME = "vasl-templates" - # --------------------------------------------------------------------- def get_git_info(): @@ -54,19 +49,26 @@ def get_git_info(): return { "last_commit_id": last_commit_id, "branch_name": branch_name } +def make_target_name( fname ): + """Generate a target filename.""" + return fname+".exe" if sys.platform == "win32" else fname + # --------------------------------------------------------------------- # parse the command-line options output_fname = None +no_loader = False work_dir = None cleanup = True -opts,args = getopt.getopt( sys.argv[1:], "o:w:", ["output=","work=","noclean"] ) -for opt,val in opts: +opts,args = getopt.getopt( sys.argv[1:], "o:w:", ["output=","no-loader","work=","no-clean"] ) +for opt, val in opts: if opt in ["-o","--output"]: output_fname = val.strip() + elif opt in ["--no-loader"]: + no_loader = True elif opt in ["-w","--work"]: work_dir = val.strip() - elif opt in ["--noclean"]: + elif opt in ["--no-clean"]: cleanup = False else: raise RuntimeError( "Unknown argument: {}".format( opt ) ) @@ -75,6 +77,7 @@ if not output_fname: # figure out where to locate our work directories if work_dir: + work_dir = os.path.abspath( work_dir ) build_dir = os.path.join( work_dir, "build" ) if os.path.isdir( build_dir ): shutil.rmtree( build_dir ) @@ -98,19 +101,23 @@ if not output_fmt: # configure pyinstaller # NOTE: Using UPX gave ~25% saving on Windows, but failed to run because of corrupt DLL's :-/ -# NOTE: Setting --specpath breaks the build - it's being used as the project root...? (!) -target_name = TARGET_NAMES.get( sys.platform, DEFAULT_TARGET_NAME ) +target_name = make_target_name( "vasl-templates" ) args = [ "--distpath", dist_dir, "--workpath", build_dir, + "--specpath", build_dir, "--onefile", "--name", target_name, ] -args.extend( [ "--add-data", "vassal-shim/release/vassal-shim.jar" + os.pathsep + "vasl_templates/webapp" ] ) +args.extend( [ "--add-data", + os.path.join( BASE_DIR, "vassal-shim/release/vassal-shim.jar" + os.pathsep + "vasl_templates/webapp" ) +] ) # NOTE: We also need to include the config/ and data/ subdirectories, but we would like to # make them available to the user, so we include them ourself in the final release archive. def map_dir( src, dest ): #pylint: disable=missing-docstring - args.extend( [ "--add-data", src + os.pathsep + dest ] ) + args.extend( [ "--add-data", + os.path.join( BASE_DIR, src + os.pathsep + dest ) + ] ) map_dir( "vasl_templates/ui", "vasl_templates/ui" ) map_dir( "vasl_templates/resources", "vasl_templates/resources" ) map_dir( "vasl_templates/webapp/static", "vasl_templates/webapp/static" ) @@ -168,6 +175,22 @@ dname = os.path.join( dist_dir, "config" ) with open( os.path.join(dname,"build-info.json"), "w" ) as fp: json.dump( build_info, fp ) +# freeze the loader +if no_loader: + print( "Not including the loader." ) +else: + print( "--- BEGIN FREEZE LOADER ---" ) + shutil.move( + os.path.join( dist_dir, target_name ), + os.path.join( dist_dir, make_target_name("vasl-templates-main") ) + ) + from loader.freeze import freeze_loader #pylint: disable=no-name-in-module + freeze_loader( + os.path.join( dist_dir, target_name ), + build_dir, # nb: a "loader" sub-directory will be created and used + False # nb: we will clean up, or not, everything ourself + ) + # create the release archive os.chdir( dist_dir ) print() @@ -179,7 +202,6 @@ print( "- Done: {0:.1f} MB".format( float(file_size) / 1024 / 1024 ) ) # clean up if cleanup: os.chdir( BASE_DIR ) # so we can delete the build directory :-/ - os.unlink( target_name + ".spec" ) shutil.rmtree( build_dir ) shutil.rmtree( dist_dir ) diff --git a/loader/assets/loading.gif b/loader/assets/loading.gif new file mode 100644 index 0000000..a27d1b4 Binary files /dev/null and b/loader/assets/loading.gif differ diff --git a/loader/freeze.py b/loader/freeze.py new file mode 100755 index 0000000..38c7f9b --- /dev/null +++ b/loader/freeze.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +""" Freeze the vasl-templates loader program. + +This script is called by the main freeze script. +""" + +import sys +import os +import shutil +import tempfile +import getopt + +from PyInstaller.__main__ import run as run_pyinstaller +from PIL import Image + +APP_ICON = os.path.join( + os.path.abspath( os.path.dirname( __file__ ) ), + "../vasl_templates/webapp/static/images/app.ico" +) + +# --------------------------------------------------------------------- + +def main( args ): + """Main processing.""" + + # parse the command-line options + output_fname = "./loader" + work_dir = os.path.join( tempfile.gettempdir(), "freeze-loader" ) + cleanup = True + opts,args = getopt.getopt( args, "o:w:", ["output=","work=","no-clean"] ) + for opt, val in opts: + if opt in ["-o","--output"]: + output_fname = val.strip() + elif opt in ["-w","--work"]: + work_dir = val.strip() + elif opt in ["--no-clean"]: + cleanup = False + else: + raise RuntimeError( "Unknown argument: {}".format( opt ) ) + + # freeze the loader program + freeze_loader( output_fname, work_dir, cleanup ) + +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +def freeze_loader( output_fname, work_dir, cleanup ): + """Freeze the loader program.""" + + with tempfile.TemporaryDirectory() as dist_dir: + + # initialize + base_dir = os.path.abspath( os.path.dirname( __file__ ) ) + assets_dir = os.path.join( base_dir, "assets" ) + + # convert the app icon to an image + if not os.path.isdir( work_dir ): + os.makedirs( work_dir ) + app_icon_fname = os.path.join( work_dir, "app-icon.png" ) + _convert_app_icon( app_icon_fname ) + + # initialize + app_name = "loader" + args = [ + "--distpath", dist_dir, + "--workpath", work_dir, + "--specpath", work_dir, + "--onefile", + "--name", app_name, + ] + args.extend( [ + "--add-data", app_icon_fname + os.pathsep + "assets/", + "--add-data", os.path.join(assets_dir,"loading.gif") + os.pathsep + "assets/" + ] ) + if sys.platform == "win32": + args.append( "--noconsole" ) + args.extend( [ "--icon", APP_ICON ] ) + args.append( os.path.join( base_dir, "main.py" ) ) + + # freeze the program + run_pyinstaller( args ) + + # save the generated artifact + fname = app_name+".exe" if sys.platform == "win32" else app_name + shutil.move( + os.path.join( dist_dir, fname ), + output_fname + ) + + # clean up + if cleanup: + shutil.rmtree( work_dir ) + +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +def _convert_app_icon( save_fname ): + """Convert the app icon to an image.""" + # NOTE: Tkinter's PhotoImage doesn't handle .ico files, so we convert the app icon + # to an image, then insert it into the PyInstaller-generated executable (so that + # we don't have to bundle Pillow into the release). + img = Image.open( APP_ICON ) + img = img.convert( "RGBA" ).resize( (64, 64) ) + img.save( save_fname, "png" ) + +# --------------------------------------------------------------------- + +if __name__ == "__main__": + main( sys.argv[1:] ) diff --git a/loader/main.py b/loader/main.py new file mode 100755 index 0000000..f5c6090 --- /dev/null +++ b/loader/main.py @@ -0,0 +1,206 @@ +""" Load the main vasl-templates program. + +vasl-templates can be slow to start (especially on Windows), since it has to unpack the PyInstaller-generated EXE, +then startup Qt. We want to show a splash screen while all this happening, but we can't just put it in vasl-templates, +since it would only happen *after* all the slow stuff has finished :-/ So, we have this stub program that shows +a splash screen, launches the main vasl-templates program, and waits for it to finish starting up. +""" + +import sys +import os +import subprocess +import threading +import itertools +import urllib.request +from urllib.error import URLError +import time +import configparser + +# NOTE: It's important that this program start up quickly (otherwise it becomes pointless), +# so we use tkinter, instead of PyQt (and also avoid bundling a 2nd copy of PyQt :-/). +import tkinter +import tkinter.messagebox + +if getattr( sys, "frozen", False ): + BASE_DIR = sys._MEIPASS #pylint: disable=no-member,protected-access +else: + BASE_DIR = os.path.abspath( os.path.dirname( __file__ ) ) + +STARTUP_TIMEOUT = 60 # how to long to wait for vasl-templates to start (seconds) + +main_window = None + +# --------------------------------------------------------------------- + +def main( args ): + """Load the main vasl-templates program.""" + + # initialize Tkinter + global main_window + main_window = tkinter.Tk() + main_window.option_add( "*Dialog.msg.font", "Helvetica 12" ) + + # load the app icon + # NOTE: This image file doesn't exist in source control, but is created dynamically from + # the main app icon by the freeze script, and inserted into the PyInstaller-generated executable. + # We do things this way so that we don't have to bundle Pillow into the release. + app_icon = tkinter.PhotoImage( + file = make_asset_path( "app-icon.png" ) + ) + + # locate the main vasl-templates executable + fname = os.path.join( os.path.dirname( sys.executable ), "vasl-templates-main" ) + if sys.platform == "win32": + fname += ".exe" + if not os.path.isfile( fname ): + show_error_msg( "Can't find the main vasl-templates program.", withdraw=True ) + return -1 + + # launch the main vasl-templates program + try: + proc = subprocess.Popen( itertools.chain( [fname], args ) ) + except Exception as ex: #pylint: disable=broad-except + show_error_msg( "Can't start vasl-templates:\n\n{}".format( ex ), withdraw=True ) + return -2 + + # get the webapp port number + port = 5010 + fname = os.path.join( os.path.dirname( fname ), "config/app.cfg" ) + if os.path.isfile( fname ): + config_parser = configparser.ConfigParser() + config_parser.optionxform = str # preserve case for the keys :-/ + config_parser.read( fname ) + args = dict( config_parser.items( "System" ) ) + port = args.get( "FLASK_PORT_NO", port ) + + # create the splash window + create_window( app_icon ) + + # start a background thread to check on the main vasl-templates process + threading.Thread( + target = check_startup, + args = ( proc, port ) + ).start() + + # run the main loop + main_window.mainloop() + + return 0 + +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +def create_window( app_icon ): + """Create the splash window.""" + + # create the splash window + main_window.geometry( "290x75" ) + main_window.title( "vasl-templates loader" ) + main_window.overrideredirect( 1 ) # nb: "-type splash" doesn't work on Windows :-/ + main_window.eval( "tk::PlaceWindow . center" ) + main_window.wm_attributes( "-topmost", 1 ) + main_window.tk.call( "wm", "iconphoto", main_window._w, app_icon ) #pylint: disable=protected-access + main_window.protocol( "WM_DELETE_WINDOW", lambda: None ) + + # add the app icon + label = tkinter.Label( main_window, image=app_icon ) + label.grid( row=0, column=0, rowspan=2, padx=5, pady=5 ) + + # add the caption + label = tkinter.Label( main_window, text="Loading vasl-templates...", font=("Helvetica",12) ) + label.grid( row=0, column=1, padx=5, pady=(5,0) ) + + # add the "loading" image (we have to animate it ourself :-/) + anim_label = tkinter.Label( main_window ) + anim_label.grid( row=1, column=1, sticky=tkinter.N, padx=0, pady=0 ) + fname = make_asset_path( "loading.gif" ) + nframes = 13 + frames = [ + tkinter.PhotoImage( file=fname, format="gif -index {}".format( i ) ) + for i in range(nframes) + ] + frame_index = 0 + def next_frame(): + nonlocal frame_index + frame = frames[ frame_index ] + frame_index = ( frame_index + 1 ) % nframes + anim_label.configure( image=frame ) + main_window.after( 75, next_frame ) + main_window.after( 0, next_frame ) + +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +def check_startup( proc, port ): + """Check the startup of the main vasl-templates process.""" + + def do_check(): + + # check if we've waited for too long + if time.time() - start_time > STARTUP_TIMEOUT: + # yup - give up + raise RuntimeError( "Couldn't start vasl-templates." ) + + # check if the main vasl-templates process has gone away + if proc.poll() is not None: + raise RuntimeError( "The vasl-templates program ended unexpectedly." ) + + # check if the webapp is responding + url = "http://localhost:{}/ping".format( port ) + try: + _ = urllib.request.urlopen( url ).read() + except URLError: + # no response - the webapp is probably still starting up + return False + except Exception as ex: #pylint: disable=broad-except + raise RuntimeError( "Couldn't communicate with vasl-templates:\n\n{}".format( ex ) ) from ex + + # the main vasl-templates program has started up and is responsive - our job is done! + if sys.platform == "win32": + # FUDGE! There is a short amount of time between the webapp server starting and + # the main window appearing. We delay here for a bit, to try to synchronize + # our window fading out with the main vasl-templates window appearing. + time.sleep( 1 ) + return True + + def on_done( msg ): + if msg: + show_error_msg( msg, withdraw=True ) + fade_out( main_window, main_window.quit ) + + # run the main loop + start_time = time.time() + while True: + try: + if do_check(): + on_done( None ) + break + except Exception as ex: #pylint: disable=broad-except + on_done( str(ex) ) + return + time.sleep( 0.25 ) + +# --------------------------------------------------------------------- + +def fade_out( target, on_done ): + """Fade out the target window.""" + alpha = target.attributes( "-alpha" ) + if alpha > 0: + alpha -= 0.1 + target.attributes( "-alpha", alpha ) + target.after( 50, lambda: fade_out( target, on_done ) ) + else: + on_done() + +def make_asset_path( fname ): + """Generate the path to an asset file.""" + return os.path.join( BASE_DIR, "assets", fname ) + +def show_error_msg( error_msg, withdraw=False ): + """Show an error dialog.""" + if withdraw: + main_window.withdraw() + tkinter.messagebox.showinfo( "vasl-templates loader error", error_msg ) + +# --------------------------------------------------------------------- + +if __name__ == "__main__": + sys.exit( main( sys.argv[1:] ) )