""" Webapp handlers. """ # Kathmandu, Nepal (NOV/18). import sys import os import shutil import subprocess import traceback import html import re import logging import pprint import base64 import time import io import zipfile import xml.etree.cElementTree as ET from flask import request, jsonify from vasl_templates.webapp import app, globvars from vasl_templates.webapp.config.constants import BASE_DIR, IS_FROZEN from vasl_templates.webapp.utils import TempFile, SimpleError, get_java_path, compare_version_strings from vasl_templates.webapp.webdriver import WebDriver from vasl_templates.webapp.vasl_mod import get_reverse_remapped_gpid # NOTE: VASSAL dropped support for Java 8 from 3.3.0. The first version of VASL that supported # the later versions of Java was 6.6.0, but it was compiled against VASSAL 3.4.2, so we don't # need to support versions of VASSAL prior to this (3.3.0-.2, 3.4.0-.1), since VASL is known # to not work with them. # The versions of VASSAL each version of VASL was compiled against, and Java bundled with # the Windows version of VASSAL are: # VASL | VASSAL Java # ------+------------------ # 6.6.0 | 3.4.2 14.0.2+12 # 6.6.1 | 3.4.6 15+36 # 6.6.2 | 3.5.5 16+36 # 6.6.3 | 3.5.8 16+36 # 6.6.4 | 3.6.6 17.0.2+8-LTS # NOTE: VASSAL+VASL back-compat has gone out the window :-/ We have to tie versions of VASL # to specific versions of VASSAL. Sigh... SUPPORTED_VASSAL_VERSIONS = { "3.4.2": [ "6.6.0", "6.6.1" ], "3.4.6": [ "6.6.0", "6.6.1" ], "3.5.5": [ "6.6.0", "6.6.1", "6.6.2" ], "3.5.8": [ "6.6.0", "6.6.1", "6.6.2", "6.6.3", "6.6.3.1" ], "3.6.6": [ "6.6.0", "6.6.1", "6.6.2", "6.6.3", "6.6.3.1", "6.6.4" ], } SUPPORTED_VASSAL_VERSIONS_DISPLAY = "3.4.2, 3.4.6, 3.5.5, 3.5.8" # --------------------------------------------------------------------- @app.route( "/update-vsav", methods=["POST"] ) def update_vsav(): #pylint: disable=too-many-statements,too-many-locals """Update labels in a VASL scenario file.""" # parse the request start_time = time.time() vsav_data = request.json[ "vsav_data" ] vsav_filename = request.json[ "filename" ] players = request.json[ "players" ] snippets = request.json[ "snippets" ] test_mode = request.json.get( "testMode" ) # initialize logger = logging.getLogger( "update_vsav" ) # update the VASL scenario file try: # get the VSAV data (we do this inside the try block so that the user gets shown # a proper error dialog if there's a problem decoding the base64 data) vsav_data = base64.b64decode( vsav_data ) logger.info( "Updating VSAV (#bytes=%d): %s", len(vsav_data), vsav_filename ) with TempFile() as input_file: # save the VSAV data in a temp file input_file.write( vsav_data ) input_file.close( delete=False ) fname = app.config.get( "UPDATE_VSAV_INPUT" ) # nb: for diagnosing problems if fname: logger.debug( "Saving a copy of the VSAV data: %s", fname ) with open( fname, "wb" ) as fp: fp.write( vsav_data ) with TempFile() as snippets_file: # save the snippets in a temp file xml = _save_snippets( snippets, players, snippets_file, test_mode, logger ) snippets_file.close( delete=False ) fname = app.config.get( "UPDATE_VSAV_SNIPPETS" ) # nb: for diagnosing problems if fname: logger.debug( "Saving a copy of the snippets: %s", fname ) with open( fname, "wb" ) as fp: ET.ElementTree( xml ).write( fp ) # run the VASSAL shim to update the VSAV file with TempFile() as output_file, TempFile() as report_file: output_file.close( delete=False ) report_file.close( delete=False ) vassal_shim = VassalShim() vassal_shim.update_scenario( input_file.name, snippets_file.name, output_file.name, report_file.name ) # read the updated VSAV data with open( output_file.name, "rb" ) as fp: vsav_data = fp.read() fname = app.config.get( "UPDATE_VSAV_RESULT" ) # nb: for diagnosing problems if fname: logger.debug( "Saving a copy of the updated VSAV: %s", fname ) with open( fname, "wb" ) as fp: fp.write( vsav_data ) # read the report report = _parse_label_report( report_file.name ) except Exception as ex: #pylint: disable=broad-except return VassalShim.translate_vassal_shim_exception( ex, logger ) # return the results logger.info( "Updated the VSAV file OK: elapsed=%.3fs", time.time()-start_time ) vsav_filename = os.path.split( vsav_filename )[1] errors = [] for fail in report["failed"]: if fail.get("message"): errors.append( "{}
{}
".format( fail["caption"], fail["message"] ) ) else: errors.append( fail["caption"] ) return jsonify( { "vsav_data": base64.b64encode(vsav_data).decode( "utf-8" ), "filename": vsav_filename, "report": { "was_modified": report["was_modified"], "labels_created": len(report["created"]), "labels_updated": len(report["updated"]), "labels_deleted": len(report["deleted"]), "labels_unchanged": len(report["unchanged"]), "errors": errors, }, } ) def _save_snippets( snippets, players, fp, test_mode, logger ): #pylint: disable=too-many-locals """Save the snippets in a file. NOTE: We save the snippets as XML because Java :-/ """ # NOTE: We used to create a WebDriver here and re-use it for each snippet screenshot, # but when we implemented the shared WebDriver, we changed things to request it for each # snippet. If we did things the old way, the WebDriver wouldn't be able to shutdown # until it had finished *all* the snippet screenshots (since we would have it locked); # the new way, we only have to wait for it to finish the snippet it's on, the WebDriver # will be unlocked, and then the other thread will be able to grab the lock and shut # it down. The downside is that if the user has to disable the shared WebDriver, things # will run ridiculously slowly, since we will be launching a new webdriver for each snippet. # We optimize for the case where things work properly... :-/ # add the player details root = ET.Element( "snippets" ) ET.SubElement( root, "player1", nat=players[0] ) ET.SubElement( root, "player2", nat=players[1] ) # FUDGE! Some of the VASSAL tests update a scenario and check what labels were updated, but this can fail # if we're using the real data files, and make a change to e.g. the common CSS (since it will cause labels # to update unexpectedly). To work-around this, if we are running tests, we do tell the VASSAL shim to do # "fuzzy" comparisons (and ignore un-important content) when deciding if a label needs to be updated. if test_mode: root.set( "fuzzyLabelCompares", "true" ) # add the snippets for snippet_id,snippet_info in snippets.items(): # add the next snippet auto_create = "true" if snippet_info["auto_create"] else "false" elem = ET.SubElement( root, "snippet", id=snippet_id, autoCreate=auto_create ) elem.text = snippet_info["content"] label_area = snippet_info.get( "label_area" ) if label_area: elem.set( "labelArea", label_area ) # add the raw content elem2 = ET.SubElement( elem, "rawContent" ) for node in snippet_info.get( "raw_content", [] ): ET.SubElement( elem2, "phrase" ).text = node # include the size of the snippet if not app.config.get( "DISABLE_UPDATE_VSAV_SCREENSHOTS" ): with WebDriver.get_instance() as webdriver: try: start_time = time.time() img = webdriver.get_snippet_screenshot( snippet_id, snippet_info["content"] ) width, height = img.size elapsed_time = time.time() - start_time logger.debug( "Generated screenshot for %s (%.3fs): %dx%d", snippet_id, elapsed_time, width, height ) # FUDGE! There's something weird going on in VASSAL e.g. "" gives us something # very different to "
" :-/ Changing the font size also causes problems. # The following fudging seems to give us something that's somewhat reasonable... :-/ if re.search( r"width:\s*?\d+?px", snippet_info["content"] ): width = int( width * 140 / 100 ) elem.set( "width", str(width) ) elem.set( "height", str(height) ) except Exception as ex: #pylint: disable=broad-except # NOTE: Don't let an error here stop the process. logging.error( "Can't get snippet screenshot: %s", ex ) logging.error( traceback.format_exc() ) ET.ElementTree( root ).write( fp ) return root def _parse_label_report( fname ): """Read the label report generated by the VASSAL shim.""" doc = ET.parse( fname ) report = { "was_modified": doc.getroot().attrib["wasModified"] == "true" } for action in doc.getroot(): nodes = [] for node in action: nodes.append( { "id": node.attrib["id"] } ) if "x" in node.attrib and "y" in node.attrib: nodes[-1]["pos"] = ( node.attrib["x"], node.attrib["y"] ) if "caption" in node.attrib: nodes[-1]["caption"] = node.attrib["caption"] if node.text: nodes[-1]["message"] = node.text report[ action.tag ] = nodes return report # --------------------------------------------------------------------- @app.route( "/analyze-vsav", methods=["POST"] ) def analyze_vsav(): #pylint: disable=too-many-locals """Analyze a VASL scenario file.""" # parse the request start_time = time.time() vsav_data = request.json[ "vsav_data" ] vsav_filename = request.json[ "filename" ] # initialize logger = logging.getLogger( "analyze_vsav" ) try: # get the VSAV data (we do this inside the try block so that the user gets shown # a proper error dialog if there's a problem decoding the base64 data) vsav_data = base64.b64decode( vsav_data ) logger.info( "Analyzing VSAV (#bytes=%d): %s", len(vsav_data), vsav_filename ) with TempFile() as input_file: # save the VSAV data in a temp file input_file.write( vsav_data ) input_file.close( delete=False ) fname = app.config.get( "ANALYZE_VSAV_INPUT" ) # nb: for diagnosing problems if fname: logger.debug( "Saving a copy of the VSAV data: %s", fname ) with open( fname, "wb" ) as fp: fp.write( vsav_data ) # run the VASSAL shim to analyze the VSAV file with TempFile() as report_file: report_file.close( delete=False ) vassal_shim = VassalShim() vassal_shim.analyze_scenario( input_file.name, report_file.name ) report = _parse_analyze_report( report_file.name ) except Exception as ex: #pylint: disable=broad-except return VassalShim.translate_vassal_shim_exception( ex, logger ) # translate any remapped GPID's back into their original values # NOTE: We need to do this e.g. if we're analyzing a scenario that was created using VASL 6.5.0 # and it contains pieces that had their GPID's changed from 6.4.4. This kind of nonsense # is probably unsustainable over the long-term, but we try to maintain some semblance of # back-compatibility for as long as we can :-/ report2 = { "pieces": {} } for gpid,vals in report.items(): orig_gpid = get_reverse_remapped_gpid( globvars.vasl_mod, gpid ) if orig_gpid == gpid: report2["pieces"][ gpid ] = vals else: report2["pieces"][ orig_gpid ] = vals # extract the VASSAL and VASL versions from the VSAV data def get_node_text( key, node_name ): elem = doc.find( "./{}".format( node_name ) ) if elem is not None: report2[ key ] = elem.text with zipfile.ZipFile( io.BytesIO( vsav_data ) ) as zfile: module_data = zfile.read( "moduledata" ) doc = ET.parse( io.BytesIO( module_data ) ) get_node_text( "vassal_version", "VassalVersion" ) get_node_text( "vasl_version", "version" ) # return the results logger.info( "Analyzed the VSAV file OK: elapsed=%.3fs\n%s", time.time() - start_time, pprint.pformat( report2, indent=2, width=120 ) ) return jsonify( report2 ) def _parse_analyze_report( fname ): """Read the analysis report generated by the VASSAL shim.""" doc = ET.parse( fname ) report = {} for node in doc.getroot(): report[ node.attrib["gpid"] ] = { "name": node.attrib["name"], "count": node.attrib["count"] } return report # --------------------------------------------------------------------- class VassalShim: """Provide access to VASSAL via the Java shim.""" _vassal_version = None def __init__( self ): #pylint: disable=too-many-branches # locate the VASSAL engine vassal_dir = self._get_vassal_dir() if not vassal_dir: raise SimpleError( "The VASSAL installation directory has not been configured." ) self.vengine_jar = None for root,_,fnames in os.walk( vassal_dir ): for fname in fnames: if fname == "Vengine.jar": self.vengine_jar = os.path.join( root, fname ) break if not self.vengine_jar: raise SimpleError( "Can't find Vengine.jar: {}".format( vassal_dir ) ) # locate the VASSAL shim JAR self.shim_jar = app.config.get( "VASSAL_SHIM" ) if not self.shim_jar: if IS_FROZEN: meipass = sys._MEIPASS #pylint: disable=no-member,protected-access self.shim_jar = os.path.join( meipass, "vasl_templates/webapp/vassal-shim.jar" ) else: self.shim_jar = os.path.join( os.path.split(__file__)[0], "../../vassal-shim/release/vassal-shim.jar" ) if not os.path.isfile( self.shim_jar ): raise SimpleError( "Can't find the VASSAL shim JAR." ) # FUDGE! The VASSAL shim looks for a config file in the same directory as itself, but if we are frozen, # the user runs the executable that unpacks everything to a temp directory and runs from there, and so # they can't set up the config file. Instead, we allow them to place it in the config/ directory and # copy it over over to the temp directory, where the VASSAL shim JAR will find it when it runs. fname = os.path.join( os.path.join(BASE_DIR,"config"), "vassal-shim.properties" ) if os.path.isfile( fname ): shutil.copy( fname, os.path.split(self.shim_jar)[0] ) @staticmethod def get_version(): """Get the VASSAL version.""" if VassalShim._vassal_version: return VassalShim._vassal_version vassal_dir = VassalShim._get_vassal_dir() if not vassal_dir: return None # FUDGE! We can't capture the output on Windows, get the result in a temp file instead :-/ with TempFile() as temp_file: temp_file.close( delete=False ) VassalShim()._run_vassal_shim( "version", temp_file.name ) #pylint: disable=protected-access with open( temp_file.name, "r", encoding="utf-8" ) as fp: VassalShim._vassal_version = fp.read() return VassalShim._vassal_version @staticmethod def is_compatible_version( vassal_version, vasl_version ): """Check if the VASSAL+VASL versions are compatible.""" return vasl_version in SUPPORTED_VASSAL_VERSIONS.get( vassal_version, [] ) def dump_scenario( self, fname ): """Dump a scenario file.""" return self._run_vassal_shim( "dump", fname ) def analyze_scenario( self, vsav_fname, report_fname ): """Analyze a scenario file.""" return self._run_vassal_shim( "analyze", vsav_fname, report_fname ) def update_scenario( self, vsav_fname, snippets_fname, output_fname, report_fname ): """Update a scenario file.""" # locate the boards return self._run_vassal_shim( "update", VassalShim.get_boards_dir(), vsav_fname, snippets_fname, output_fname, report_fname ) def analyze_logfiles( self, *fnames ): """Analyze a log file.""" return self._run_vassal_shim( "analyzeLogs", *fnames ) def prepare_asa_upload( self, vsave_fname, stripped_vsav_fname, screenshot_fname ): """Prepare files for upload to the ASL Scenario Archive.""" return self._run_vassal_shim( "prepareUpload", vsave_fname, stripped_vsav_fname, screenshot_fname ) def _run_vassal_shim( self, *args ): #pylint: disable=too-many-locals """Run the VASSAL shim.""" # NOTE: If VASSAL is taking a really long time to run (when it's loading the VASL module), # check if any NIC's are enabled, but there isn't actually any internet access. I suspect # that VASL is checking for something online, and taking a really long time to time-out. # If there are no NIC's, then the attempt to connect fails immediately, but there are, # the networking stack tries to find a route online. # initialize logger = logging.getLogger( "vassal_shim" ) # locate the Java runtime java_path = get_java_path() java8_path = app.config.get( "JAVA8_PATH" ) if java8_path: # FUDGE! From 3.3, VASSAL no longer works with Java 8. We want to mantain back-compatibility # with the older versions of VASL (6.5.1 and older) for some time, and while it's not a big issue # from the user's perspective (they just configure the appropriate VASSAL+VASL), it's problematic # for the test suite (since it has to be able to run the correct version of Java for the VASSAL # being used). We do this here, and since it's just for the purpose of running tests, we can # require that the VASSAL version be embedded in the filename. # NOTE: I eventually gave up trying to maintain back-compat with older versions of VASL, but # the GPID remapping test (test_gpid_remapping() in test_counters.py) is an important one, # but is currently only relevant for 6.4.4 and 6.5.0-.1, so for the sole purpose of being able # to run those tests, we still support Java 8. Sigh... mo = re.search( r"\d+\.\d+\.\d+", self.vengine_jar ) if compare_version_strings( mo.group(), "3.3.0" ) < 0: # we're using a legacy version of VASSAL - use Java 8 java_path = java8_path # prepare the command class_path = app.config.get( "JAVA_CLASS_PATH" ) if not class_path: class_path = [ self.vengine_jar, self.shim_jar ] class_path.append( os.path.split( self.shim_jar )[0] ) # nb: to find logback(-test).xml if IS_FROZEN: class_path.append( BASE_DIR ) # nb: also to find logback(-test).xml sep = ";" if os.name == "nt" else ":" class_path = sep.join( class_path ) args2 = [ java_path, "-classpath", class_path, "vassal_shim.Main", args[0] ] if args[0] in ("dump","analyze","analyzeLogs","update","prepareUpload"): if not globvars.vasl_mod: raise SimpleError( "The VASL module has not been configured." ) args2.append( globvars.vasl_mod.filename ) args2.extend( args[1:] ) # figure out how long to the let the VASSAL shim run # NOTE: This used to be 2 minutes, but adding the ability to load images from the internet # slows the process down, since VASSAL loads images insanely slowly :-/ timeout = int( app.config.get( "VASSAL_SHIM_TIMEOUT", 5*60 ) ) if timeout <= 0: timeout = None # run the VASSAL shim logger.info( "Running VASSAL shim (timeout=%s): %s", str(timeout), " ".join(args2) ) start_time = time.time() # NOTE: We can't use pipes to capture the output here when we're frozen on Windows ("invalid handle" errors), # I suspect because we freeze the application using --noconsole, which causes problems when # the child process tries to inherit handles. Capturing the output in temp files also fails (!), # as does using subprocess.DEVNULL (!!!) Setting close_fds when calling Popen() also made no difference. # The only thing that worked was removing "--noconsole" when freezing the application, but that causes # a DOS box to appear when we are run :-/ # However, we can also not specify any stdout/stderr, and since we don't actually check the output, # we can get away with this, even if it is a bit icky :-/ However, if the VASSAL shim throws an error, # we won't be able to show the stack trace, just a generic "VASSAL shim failed" message :-( with TempFile() as buf1, TempFile() as buf2: kwargs = {} if not ( os.name == "nt" and IS_FROZEN ): kwargs = { "stdout": buf1.temp_file, "stderr": buf2.temp_file } if os.name == "nt": # NOTE: Using CREATE_NO_WINDOW doesn't fix the problem of VASSAL's UI sometimes appearing, # but it does hide the DOS box if the user has configured java.exe instead of javaw.exe. kwargs["creationflags"] = 0x8000000 # nb: win32process.CREATE_NO_WINDOW try: with subprocess.Popen( args2, **kwargs ) as proc: try: proc.wait( timeout ) except subprocess.TimeoutExpired: proc.kill() raise except FileNotFoundError as ex: raise SimpleError( "Can't run the VASSAL shim (have you configured Java?): {}".format( ex ) ) from ex buf1.close( delete=False ) with open( buf1.name, "r", encoding="utf-8" ) as fp: stdout = fp.read() buf2.close( delete=False ) with open( buf2.name, "r", encoding="utf-8" ) as fp: stderr = fp.read() elapsed_time = time.time() - start_time logger.info( "- Completed OK: %.3fs", elapsed_time ) # check the result stderr = stderr.replace( "Warning: Could not get charToByteConverterClass!", "" ).strip() # NOTE: VASSAL's internal representation of a scenario seems to be tightly coupled with its UI, # which means that when we load a scenario, bits of the UI sometimes start appearing (although not always, # presumably because there's a race between how fast we can make our changes and save the scenario # vs. how fast the UI can start up :-/). When the UI does start to appear, it fails, presumably because # we haven't performed the necessary startup incantations, and dumps a stack trace to stderr. # The upshot is that the only thing we look for is an exit code of 0, which means that the VASSAL shim # saved the scenario successfully and exited cleanly; any output on stderr means that some part # of VASSAL barfed as it was trying to start up and can (hopefully) be safely ignored. if stderr: logger.warning( "VASSAL shim stderr output:\n%s", stderr ) if proc.returncode != 0: raise VassalShimError( proc.returncode, stdout, stderr ) return stdout @staticmethod def get_boards_dir(): """Get the configured boards directory.""" boards_dir = app.config.get( "BOARDS_DIR" ) if not boards_dir: raise SimpleError( "The VASL boards directory has not been configured." ) if not os.path.isdir( boards_dir ): raise SimpleError( "Can't find the VASL boards: {}".format( boards_dir ) ) return boards_dir @staticmethod def translate_vassal_shim_exception( ex, logger ): """Convert an exception thrown by the VassalShim to a JSON response to return to the caller.""" if isinstance( ex, VassalShimError ): logger.error( "VASSAL shim error: rc=%d", ex.retcode ) if ex.retcode != 0: return jsonify( { "error": "Unexpected return code from the VASSAL shim: {}".format( ex.retcode ), "stdout": ex.stdout, "stderr": ex.stderr, } ) return jsonify( { "error": "Unexpected error output from the VASSAL shim.", "stdout": ex.stdout, "stderr": ex.stderr, } ) if isinstance( ex, subprocess.TimeoutExpired ): return jsonify( { "error": "

The VASSAL shim took too long to run, please try again." \ "

If this problem persists, try configuring a longer timeout." } ) if isinstance( ex, SimpleError ): logger.error( "VASSAL shim error: %s", ex ) return jsonify( { "error": str(ex) } ) logger.error( "Unexpected VASSAL shim error: %s", ex ) return jsonify( { "error": str(ex), "stdout": traceback.format_exc(), } ) @staticmethod def check_vassal_version( msg_store ): """Check the version of VASSAL.""" if not VassalShim._get_vassal_dir() or not msg_store: return try: version = VassalShim.get_version() except Exception as ex: #pylint: disable=broad-except if msg_store: # NOTE: We get here if Java is unable to run the VASSAL shim e.g. because it's too much older # than the version used to compile the shim. We normally show the user detailed error output # from VASSAL in a dialog, but getting the VASSAL version number is a bit different, since it's # an unsolicited action done as we start up, so showing the error to the user is problematic, # since we don't have a way to trigger the dialog. We could add a new field to MsgStore for # detailed messages that should be shown in a dialog, but what if e.g. there's more than one? # It gets messy real fast, so we just extract the important part of the error output and # show it to the user in a normal error balloon. msg = str( ex ) if isinstance( ex, VassalShimError ): mo = re.search( r'Exception in thread "main" (.+?)$', ex.stderr or ex.stdout, re.MULTILINE ) if mo: msg = mo.group( 1 ) msg_store.error( "Can't get the VASSAL version:

{}
", html.escape( msg ) ) return if version not in SUPPORTED_VASSAL_VERSIONS: if msg_store: msg_store.warning( "This program has not been tested with VASSAL {}.

Things might work, but they might not...", version ) elif globvars.vasl_mod: if not VassalShim.is_compatible_version( version, globvars.vasl_mod.vasl_version ): if msg_store: msg_store.error( "VASSAL {} and VASL {} are not compatible.".format( version, globvars.vasl_mod.vasl_version ), version ) @staticmethod def _get_vassal_dir(): """Get the VASSAL installation directory.""" return app.config.get( "VASSAL_DIR" ) # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - class VassalShimError( Exception ): """Represents an error returned by the VASSAL shim.""" def __init__( self, retcode, stdout, stderr ): super().__init__() self.retcode = retcode self.stdout = stdout self.stderr = stderr def __str__( self ): return "VassalShim error: rc={}".format( self.retcode )