diff --git a/requirements.txt b/requirements.txt index 399f321..2ca2c6c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,5 @@ pyyaml==6.0 pillow==9.1.1 selenium==4.2.0 waitress==2.1.2 +appdirs==1.4.4 click==8.1.3 diff --git a/vasl_templates/main.py b/vasl_templates/main.py index 8e1a603..fb0b741 100755 --- a/vasl_templates/main.py +++ b/vasl_templates/main.py @@ -22,6 +22,10 @@ from PyQt5.QtCore import Qt, QSettings, QDir import PyQt5.QtCore import click +# notify everyone that we're being run as the desktop application +os.environ[ "IS_DESKTOP_APP" ] = "1" +os.environ[ "QDIR_HOME_PATH" ] = QDir.homePath() + from vasl_templates.webapp.utils import SimpleError, is_windows # NOTE: We're supposed to do the following to support HiDPI, but it causes the main window @@ -95,7 +99,7 @@ def _do_main( template_pack, default_scenario, remote_debugging, debug ): #pylin # NOTE: We do these imports here (instead of at the top of the file) so that we can catch errors. from vasl_templates.webapp import app as webapp - from vasl_templates.webapp import load_debug_config + from vasl_templates.webapp import globvars, load_debug_config from vasl_templates.webapp import main as webapp_main, snippets as webapp_snippets # configure the default template pack @@ -121,12 +125,12 @@ def _do_main( template_pack, default_scenario, remote_debugging, debug ): #pylin os.environ["QTWEBENGINE_REMOTE_DEBUGGING"] = remote_debugging # load the application settings - app_settings_fname = "vasl-templates.ini" if sys.platform == "win32" else ".vasl-templates.conf" - if not os.path.isfile( app_settings_fname ) : - app_settings_fname = os.path.join( QDir.homePath(), app_settings_fname ) # FUDGE! Declaring app_settings as global here doesn't work on Windows (?!), we have to do this weird import :-/ import vasl_templates.main #pylint: disable=import-self - vasl_templates.main.app_settings = QSettings( app_settings_fname, QSettings.IniFormat ) + vasl_templates.main.app_settings = QSettings( + globvars.user_profile.desktop_settings_fname, + QSettings.IniFormat + ) # install the debug config file if debug: diff --git a/vasl_templates/webapp/__init__.py b/vasl_templates/webapp/__init__.py index 03c6f8d..3efaade 100644 --- a/vasl_templates/webapp/__init__.py +++ b/vasl_templates/webapp/__init__.py @@ -5,7 +5,6 @@ import os import signal import threading import time -import tempfile import configparser import logging import logging.config @@ -17,7 +16,6 @@ import yaml from vasl_templates.webapp.config.constants import BASE_DIR shutdown_event = threading.Event() -_LOCK_FNAME = os.path.join( tempfile.gettempdir(), "vasl-templates.lock" ) # --------------------------------------------------------------------- @@ -92,9 +90,7 @@ def _init_webapp(): elif dname: webapp_vo_notes._vo_notes_image_cache_dname = dname #pylint: disable=protected-access else: - webapp_vo_notes._vo_notes_image_cache_dname = os.path.join( #pylint: disable=protected-access - tempfile.gettempdir(), "vasl-templates", "vo-notes-image-cache" - ) + webapp_vo_notes._vo_notes_image_cache_dname = globvars.user_profile.vo_notes_image_cache_dname #pylint: disable=protected-access # load integration data from asl-rulebook2 from vasl_templates.webapp.vo_notes import load_asl_rulebook2_vo_note_targets #pylint: disable=cyclic-import @@ -159,13 +155,13 @@ def _on_sigint( signum, stack ): #pylint: disable=unused-argument shutdown_event.set() # call any registered cleanup handlers - from vasl_templates.webapp import globvars #pylint: disable=cyclic-import for handler in globvars.cleanup_handlers: handler() + lock_fname = globvars.user_profile.flask_lock_fname if _is_flask_child_process(): # notify the parent process that we're done - os.unlink( _LOCK_FNAME ) + os.unlink( lock_fname ) else: # we are the Flask parent process (so we wait for the child process to finish) or Flask reloading # is disabled (and the wait below will end immediately, because the lock file was never created). @@ -176,7 +172,7 @@ def _on_sigint( signum, stack ): #pylint: disable=unused-argument # NOTE: os.path.isfile() and .exists() both return True even after the log file has gone!?!? # Is somebody caching something somewhere? :-/ try: - with open( _LOCK_FNAME, "rb" ): + with open( lock_fname, "rb" ): pass except FileNotFoundError: break @@ -187,10 +183,6 @@ def _on_sigint( signum, stack ): #pylint: disable=unused-argument # initialize Flask app = Flask( __name__ ) -if _is_flask_child_process(): - # we are the Flask child process - create a lock file - with open( _LOCK_FNAME, "wb" ): - pass # set config defaults # NOTE: These are defined here since they are used by both the back- and front-ends. @@ -201,19 +193,30 @@ app.config[ "ASA_GET_SCENARIO_URL" ] = "https://aslscenarioarchive.com/rest/scen app.config[ "ASA_MAX_VASL_SETUP_SIZE" ] = 200 # nb: KB app.config[ "ASA_MAX_SCREENSHOT_SIZE" ] = 200 # nb: KB +# initialize logging +_config_dir = os.path.join( BASE_DIR, "config" ) +_fname = os.path.join( _config_dir, "logging.yaml" ) +if os.path.isfile( _fname ): + with open( _fname, "r", encoding="utf-8" ) as fp: + try: + logging.config.dictConfig( yaml.safe_load( fp ) ) + except Exception as _ex: #pylint: disable=broad-except + logging.error( "Can't load the logging config: %s", _ex ) +else: + # stop Flask from logging every request :-/ + logging.getLogger( "werkzeug" ).setLevel( logging.WARNING ) + # load the application configuration -config_dir = os.path.join( BASE_DIR, "config" ) -_fname = os.path.join( config_dir, "app.cfg" ) +_fname = os.path.join( _config_dir, "app.cfg" ) _load_config( _fname, "System" ) # load any site configuration -_fname = os.path.join( config_dir, "site.cfg" ) +_fname = os.path.join( _config_dir, "site.cfg" ) _load_config( _fname, "Site Config" ) # load any debug configuration -_fname = os.path.join( config_dir, "debug.cfg" ) -if os.path.isfile( _fname ) : - load_debug_config( _fname ) +_fname = os.path.join( _config_dir, "debug.cfg" ) +load_debug_config( _fname ) # load any config from environment variables (e.g. set in the Docker container) # NOTE: We could add these settings to the container's site.cfg, so that they are always defined, and things @@ -230,17 +233,16 @@ _set_config_from_env( "USER_FILES_DIR" ) # NOTE: The Docker container also sets DEFAULT_TEMPLATE_PACK, but we read it directly from # the environment variable, since it is not something that is stored in app.config. -# initialize logging -_fname = os.path.join( config_dir, "logging.yaml" ) -if os.path.isfile( _fname ): - with open( _fname, "r", encoding="utf-8" ) as fp: - try: - logging.config.dictConfig( yaml.safe_load( fp ) ) - except Exception as _ex: #pylint: disable=broad-except - logging.error( "Can't load the logging config: %s", _ex ) -else: - # stop Flask from logging every request :-/ - logging.getLogger( "werkzeug" ).setLevel( logging.WARNING ) +# initialize the user profile +from vasl_templates.webapp.user_profile import UserProfile #pylint: disable=cyclic-import +from vasl_templates.webapp import globvars #pylint: disable=cyclic-import +globvars.user_profile = UserProfile( app.config ) + +# check if we are the Flask child process +if _is_flask_child_process(): + # yup - create a lock file + with open( globvars.user_profile.flask_lock_fname, "wb" ): + pass # load the application import vasl_templates.webapp.main #pylint: disable=cyclic-import diff --git a/vasl_templates/webapp/config/logging.yaml.example b/vasl_templates/webapp/config/logging.yaml.example index e79f773..9bc4a07 100644 --- a/vasl_templates/webapp/config/logging.yaml.example +++ b/vasl_templates/webapp/config/logging.yaml.example @@ -63,3 +63,7 @@ loggers: level: "WARNING" handlers: [ "console", "file" ] propagate: 0 + user_profile: + level: "WARNING" + handlers: [ "console", "file" ] + propagate: 0 diff --git a/vasl_templates/webapp/downloads.py b/vasl_templates/webapp/downloads.py index b3666bd..bdbd4b3 100644 --- a/vasl_templates/webapp/downloads.py +++ b/vasl_templates/webapp/downloads.py @@ -11,10 +11,9 @@ import urllib.error import gzip import time import datetime -import tempfile import logging -from vasl_templates.webapp import app +from vasl_templates.webapp import app, globvars from vasl_templates.webapp.utils import parse_int _registry = set() @@ -27,12 +26,11 @@ _etags = {} class DownloadedFile: """Manage a downloaded file.""" - def __init__( self, key, ttl, fname, url, on_data, extra_args=None ): + def __init__( self, key, ttl, url, on_data, extra_args=None ): # initialize self.key = key self.ttl = ttl - self.fname = fname self.url = url self.on_data = on_data self.error_msg = None @@ -50,7 +48,7 @@ class DownloadedFile: _registry.add( self ) # check if we have a cached copy of the file - self.cache_fname = os.path.join( tempfile.gettempdir(), "vasl-templates."+fname ) + self.cache_fname = globvars.user_profile.downloaded_files[ self.key ] if os.path.isfile( self.cache_fname ): # yup - load it _logger.info( "Using cached %s file: %s", key, self.cache_fname ) diff --git a/vasl_templates/webapp/globvars.py b/vasl_templates/webapp/globvars.py index 9492b34..bc2db43 100644 --- a/vasl_templates/webapp/globvars.py +++ b/vasl_templates/webapp/globvars.py @@ -3,6 +3,7 @@ from vasl_templates.webapp import app from vasl_templates.webapp.config.constants import APP_NAME, APP_VERSION +user_profile = None template_pack = None vasl_mod = None vo_listings = None diff --git a/vasl_templates/webapp/scenarios.py b/vasl_templates/webapp/scenarios.py index deeb6c7..19f519d 100644 --- a/vasl_templates/webapp/scenarios.py +++ b/vasl_templates/webapp/scenarios.py @@ -48,7 +48,6 @@ def _tidyup_strings( scenario ): scenario[key] = val.strip() _asa_scenarios = DownloadedFile( "ASA", 6, # nb: TTL = #hours - "asl-scenario-archive.json", "https://vasl-templates.org/services/asl-scenario-archive/scenario-index.json", _build_asa_scenario_index, extra_args = { "index": None } @@ -93,7 +92,6 @@ def _make_roar_matching_key( val ): return re.sub( "[^a-z0-9]", "", val.lower() ) _roar_scenarios = DownloadedFile( "ROAR", 6, # nb: TTL = #hours - "roar-scenario-index.json", "https://vasl-templates.org/services/roar/scenario-index.json", _build_roar_scenario_index, extra_args = { "index": None } diff --git a/vasl_templates/webapp/templates/configure-vo-notes-image-cache.html b/vasl_templates/webapp/templates/configure-vo-notes-image-cache.html index 81d73ce..f3ebe3a 100644 --- a/vasl_templates/webapp/templates/configure-vo-notes-image-cache.html +++ b/vasl_templates/webapp/templates/configure-vo-notes-image-cache.html @@ -1,7 +1,7 @@ If you have set up your vehicle/ordnance notes using HTML, and have configured "Show Chapter H vehicle/ordnance notes as images" in the Settings, you will find that scenarios can be slow to open in VASSAL, since converting each note to an image takes time. -

This process can be optimized by configuring a directory to cache the generated images, and this page will pre-load the cache with all the available vehicle/ordnance notes. +

These images will be cached, but you can speed things up even more by pre-loading the cache with all the available vehicle/ordnance notes. {%if NO_CACHE_DIR %} -

Configure VO_NOTES_IMAGE_CACHE_DIR in your site.cfg, restart the server, then reload this page. +

Your cache is currently disabled (VO_NOTES_IMAGE_CACHE_DIR in your site.cfg), so enable it, restart the server, then reload this page. {%endif%} diff --git a/vasl_templates/webapp/user_profile.py b/vasl_templates/webapp/user_profile.py new file mode 100644 index 0000000..730ea66 --- /dev/null +++ b/vasl_templates/webapp/user_profile.py @@ -0,0 +1,153 @@ +""" Manage information about the current user. """ + +import sys +import os +import shutil +import tempfile +import logging + +import appdirs + +_APP_NAME = "vasl-templates" +_APP_AUTHOR = "pacman-ghost" + +_logger = logging.getLogger( "user_profile" ) + +# --------------------------------------------------------------------- + +class UserProfile: + """Manage information about the current user.""" + + def __init__( self, app_config ): + + # initialize + is_desktop_app = os.environ.get( "IS_DESKTOP_APP" ) is not None + is_container = app_config.get( "IS_CONTAINER" ) is not None + + # configure the location of the user's config files + if is_desktop_app: + self.config_dname = "/tmp" if is_container \ + else self._check_dir( appdirs.user_config_dir( _APP_NAME, _APP_AUTHOR, roaming=True ) ) + self.desktop_settings_fname = os.path.join( self.config_dname, + "settings.ini" if sys.platform == "win32" else "settings.conf" + ) + + # configure the location of the user's local data files + self.local_data_dname = "/tmp" if is_container \ + else self._check_dir( appdirs.user_data_dir( _APP_NAME, _APP_AUTHOR, roaming=False ) ) + self.flask_lock_fname = os.path.join( self.local_data_dname, "flask.lock" ) + + # configure the location of the log files + self.logs_dname = "/tmp" if is_container \ + else self._check_dir( appdirs.user_log_dir( _APP_NAME, _APP_AUTHOR ) ) + self.webdriver_log_fname = app_config.get( "WEBDRIVER_LOG", + os.path.join( self.logs_dname, "webdriver.log" ) + ) + + # configure the location of the cached data files + self.cache_dname = "/tmp" if is_container \ + else self._check_dir( appdirs.user_cache_dir( _APP_NAME, _APP_AUTHOR ) ) + self.downloaded_files = { + "ASA": os.path.join( self.cache_dname, "asl-scenario-archive.json" ), + "ROAR": os.path.join( self.cache_dname, "roar-scenario-index.json" ), + } + self.vo_notes_image_cache_dname = self._check_dir( os.path.join( self.cache_dname, "vo-notes-image-cache" ) ) + + # log our settings + _logger.info( "UserProfile:" ) + if is_desktop_app: + _logger.info( "- config = %s", self.config_dname ) + _logger.debug( " - settings = %s", self.desktop_settings_fname ) + _logger.info( "- local data = %s", self.local_data_dname ) + _logger.debug( " - Flask lock file = %s", self.flask_lock_fname ) + _logger.info( "- logs = %s", self.logs_dname ) + _logger.debug( " - webdriver = %s", self.webdriver_log_fname ) + _logger.info( "- cache = %s", self.cache_dname ) + for key, val in self.downloaded_files.items(): + _logger.debug( " - Downloaded file (%s) = %s", key, val ) + _logger.debug( " - V/O note image cache = %s", self.vo_notes_image_cache_dname ) + + # fixup any legacy files + if not is_container: + self._fixup_legacy() + + def _fixup_legacy( self ): + """Fixup any legacy files and directories. + + NOTE: Config and data files were moved to the standard locations in v1.10.beta3; this function + looks for them in the legacy locations and moves them to their new location. + """ + + def move_file( caption, src_fname, dest_fname ): + if not os.path.isfile( src_fname ): + _logger.debug( "Legacy %s file not found: %s", caption, src_fname ) + return + _logger.info( "Moving legacy %s file:\n- from: %s\n- to: %s", + caption, src_fname, dest_fname + ) + try: + shutil.move( src_fname, dest_fname ) + except Exception as ex: #pylint: disable=broad-except + # NOTE: It would be nice to report this as a startup error, but this happens + # so early in the startup process, nothing has been initialized yet :-/ + logging.error( "Can't move legacy %s file: %s\n- %s", caption, src_fname, ex ) + # NOTE: We try to keep going. + + def remove_dir( caption, dname ): + if not os.path.isdir( dname ): + _logger.debug( "Legacy %s directory not found: %s", caption, dname ) + return + _logger.info( "Deleting legacy %s directory: %s", caption, dname ) + try: + shutil.rmtree( dname ) + except Exception as ex: #pylint: disable=broad-except + logging.error( "Can't delete legacy %s directory: %s\n- %s", caption, dname, ex ) + # NOTE: We try to keep going. + + # fixup the desktop settings file + qdir_home_path = os.environ.get( "QDIR_HOME_PATH" ) + if qdir_home_path: + fname = os.path.join( qdir_home_path, + "vasl-templates.ini" if sys.platform == "win32" else ".vasl-templates.conf" + ) + move_file( "desktop settings", fname, self.desktop_settings_fname ) + + # fixup the ASA/ROAR downloaded files + # NOTE: We don't *need* to do this (since the files will just be downloaded again), and they're + # in the temp directory (so we don't *need* to remove them), but it'd be nice to have scenario search + # working straight away after startup. + move_file( "ASA scenarios", + os.path.join( tempfile.gettempdir(), "vasl-templates.asl-scenario-archive.json" ), + self.downloaded_files[ "ASA" ] + ) + move_file( "ROAR scenarios", + os.path.join( tempfile.gettempdir(), "vasl-templates.roar-scenario-index.json" ), + self.downloaded_files[ "ROAR" ] + ) + + # NOTE: The Flask lock file and webdriver log file are temp files, so we don't move them. + + # NOTE: The V/O notes image cache can either be: + # - disabled + # - manually configured (via VO_NOTES_IMAGE_CACHE_DIR) + # - default ($TEMP-DIR/vasl-templates/vo-notes-image-cache/) + # In the first 2 cases, we don't need to do anything, in the last case, we delete our temp directory. + # Note that while we could try to move it, it may well not be on the same file system (which makes it + # a non-trivial operation), and there have been CSS changes in the v1.10 release cycle, which normally + # won't cause an image to be re-generated, so it's not a bad idea to force this to happen. + remove_dir( "app temp", + os.path.join( tempfile.gettempdir(), "vasl-templates" ) + ) + + @staticmethod + def _check_dir( dname ): + """Check that a directory exists.""" + try: + if not os.path.isdir( dname ): + os.makedirs( dname ) + except Exception as ex: #pylint: disable=broad-except + logging.error( "Can't create UserProfile directory: %s\n- %s", dname, ex ) + raise + if dname[-1] != os.sep: + dname += os.sep + return dname diff --git a/vasl_templates/webapp/vo_notes.py b/vasl_templates/webapp/vo_notes.py index b668fc9..bf6bac8 100644 --- a/vasl_templates/webapp/vo_notes.py +++ b/vasl_templates/webapp/vo_notes.py @@ -377,8 +377,7 @@ def _make_vo_note_cached_image_fname( vo_type, nat, key ): @app.route( "/load-vo-notes-image-cache" ) def load_vo_notes_image_cache(): """Show the helper page to preload the v/o notes image cache.""" - dname = app.config.get( "VO_NOTES_IMAGE_CACHE_DIR" ) - if not dname: + if not _vo_notes_image_cache_dname: return render_template( "configure-vo-notes-image-cache.html", NO_CACHE_DIR = True ) diff --git a/vasl_templates/webapp/webdriver.py b/vasl_templates/webapp/webdriver.py index 8bce826..71a8b74 100644 --- a/vasl_templates/webapp/webdriver.py +++ b/vasl_templates/webapp/webdriver.py @@ -1,9 +1,7 @@ """ Wrapper for a Selenium webdriver. """ -import os import threading import io -import tempfile import atexit import logging @@ -60,9 +58,7 @@ class WebDriver: # create the webdriver _logger.debug( "- Launching webdriver process: %s", webdriver_path ) - log_fname = app.config.get( "WEBDRIVER_LOG", - os.path.join( tempfile.gettempdir(), "webdriver.log" ) - ) + log_fname = globvars.user_profile.webdriver_log_fname if "chromedriver" in webdriver_path: options = webdriver.ChromeOptions() options.headless = True