From 903f2f29ffeb061e5d5fd3a7fac9cbfdc8fc7600 Mon Sep 17 00:00:00 2001 From: Taka Date: Fri, 1 Oct 2021 02:09:25 +1000 Subject: [PATCH] Show a splash screen during startup. --- freeze.py | 48 ++++++--- loader/assets/loading.gif | Bin 0 -> 3444 bytes loader/freeze.py | 107 ++++++++++++++++++++ loader/main.py | 206 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 348 insertions(+), 13 deletions(-) create mode 100644 loader/assets/loading.gif create mode 100755 loader/freeze.py create mode 100755 loader/main.py 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 0000000000000000000000000000000000000000..a27d1b492016345a63ad994fcf686e0707462098 GIT binary patch literal 3444 zcmc)NX;f2Z9tQABa&w8~;&4%025=ezRR*vEGE;Ct0t5mC1q4|X2oUx~5@A?{Ko&wI zA;AEO5K$IU5yPfbDx#vYxgA@@1+9YCt#X{IWvs39hJfHXo_-}?a!!_W-{<%K?|Yu> z?%`(d5a9#qKxZK+H!pYm@pyGrb+xkk!-o&h%**5hX#>rFlMB^{X8VmbUJHC~@;`XN z0tkkz)W1>x9m0pvPu~60deGy@Df{P_en-#gS=Z8qV}6Z;*;N+r+W1E21|bMn_iIn+ zwpT`v`BXCYo&P^-_!O63f1mw}30#mv2WVCcn?S zGV!hc^d)s?FUf;(Fj(n}XD`6Q`_A^E3(@Ao&9C3SUi`)4`UCZg^%pm{G{1lM{$mt) zhiX8xyr;pW>k?MHJ^>>0pY}}14?D5x&SgaZp5IpA>s#JOC^-GVcaPmNUAqwHG6NK+ zc=oTv^BX$C5-Pj@kY0*r|J{(lFk*5H@-|-?dy-S5e4G>Q`|k}jT~|6^$Pjt)ilqAH z7K9bnJ$YX}`h9zDPIg|QfRAmY zCT~mSVEH_*OjsaNRLYBXRbd{Uj17sto3yl;7_PDD@WG%X$6CTv?QIRm?R9k4I&LJm zx}P~~i4uu^=LQCchTTm*Z^AF+0}YTG>@6?>L;wM7CNKd4fCY%DH@UGMY%gH>xWj*C zG4mavyj7)JAp;~yvj9bVr;F$`F^F>8Mv6R9INXJ5avU8z4 z48hCtYfF@M{6=xpf&C5p;Sdr`@ec?L-o7;oLAD`DCr^jaH zbUhe1Zdz&+iJ>F+?*H__a$cZL%tAeP`x|cQw&Zq(o^_qAout6oEC_-YoJoEfX>Yt1 zt7BWaYF`{9bF5>&><}&TG<0vc(rzCbQ#x#Rj?6S5v~jsj+?B3+neIdiqJ>X46%?_B zBEF<#XR)jpUxsA_XJcUj`2`RU<5bpFNE;jKn~ty#Mk6c=90s(4g13ZbGW`%F0c}0o zr)oRhA9=wN-5%>mAUV5+>3TA5-Bw#g+4@hSNt&nh?5 z5~NLUDOt|ze=r&PgX)T9m4$cUs418#^1#!#S)q$3aX1PgkAM;L=u@+mBQB9*J0<^- zWhvPB-Qp_gj&Lr<+9D`|ipy#$<($2TT2Lg3(%K%>76`KdS6FyNR9;L6n61wKu7DxN za&&v(dIHIX#?tk=eCMt?z=|Cm8-HXmFIaQOs^{5fv5LT0X|QOUKJq~;!0 zJ*r9aRqTG(0Iapjg69@htGD0_$jO-t#;Rv(tY|l${W=(DM!H6|br2cL#sn+M%2TFq zK($oqiBen?1gr{xRe{IUWMxQ7xgOnnU1!gE{LghHa;Sl1d&Zf9E{g5SM+7N(SAJi+Hin#s#^%oEc0?+ze!fu zT?#nXoy1q3bKdp0eEI5CMoyNgR;(IJ2(W~n#5_EHN9wL}wj#5tu)0iHp%lc5u%swS zDI^a?LV{YtTm9SDbgGUWJq9NMsF>LJL}AM6iz|T=l|Z7oC+T|k|9FD}l(yX*9i15` zbhc!wYceQQpxJ|xsi{wz&Z#NR>VDN&Kes`_3fXi`?*70umzrh5XKb+I185)L3UR*l z#{15HTYg?w3tu#PXQoYne0*$>p-0R}x*0lEll{8y{t{?)EqjRRLD8Yp71z#A4qK!B zZ`O=Tf1Y>~%=Irs6Ma)qLK4RXaYk|O`tni*O;)zMd{4z9oS1>r_wn8I^}#0X%^Oe{ zPas$~wRdzLKVdM_o^?JAHvPz=tk#r5kQ1cRl>fPrG~Ey8`OFQ*a#$i{`)J!XU$-Hv z%*dkNlbw|7iY+O>MPABwt{HcNAYvz#44z^R-H1=;jC!67whA~G>qp> zS{B_rnvtS(TtD&0_JqV_uT`D`7aO5lv1pwwO)PPAca!Vjyf+ptt5Mo)TDPyRzM-)O HgVy{X0TuDr literal 0 HcmV?d00001 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:] ) )