#!/usr/bin/env python3
# Copyright (c) 2017-2026 Soft8Soft, LLC. All rights reserved.
#
# Use of this software is subject to the terms of the Verge3D license
# agreement provided at the time of installation or download, or which
# otherwise accompanies this software in either electronic or hard copy form.

import os, sys

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(BASE_DIR, 'lib'))

import base64, datetime, difflib, filecmp, hashlib, io, json, logging, mimetypes, pathlib, platform, re, shutil, signal, socket, ssl, string, subprocess, tempfile, uuid, zipfile, zipp

import urllib.parse, urllib.request

import plistlib

import asyncio
import concurrent.futures

import tornado.ioloop
import tornado.web
import tornado.log

import ziptools

from mako.lookup import TemplateLookup, Template
import markupsafe
import boto3, botocore.client

from xml.dom import minidom

import keymanager, native_dialogs, send2trash

import appparse
from appparse import filterTokens, filterTokensAll, nthOfType

from profilehooks import profile

from html import escape as htmlEscape

PORTS = {
    'BLENDER': [8668],
    'MAX': [8669],
    'MAYA': [8670],
    'ALL': [8668, 8669, 8670]
}

PORT_TO_PACK = {
    8668: 'BLENDER',
    8669: 'MAX',
    8670: 'MAYA'
}

APPS_PREFIX = '/applications/'

KEY_LEN = 54
HASH_SIZE = 0xFFFF

TEST_APP_PREFIX = r'^\d+\.'

SETTINGS_JSON = 'settings.json'

APP_TEMPLATE_JSON = 'template.json'
NATIVE_APP_SETTINGS_JSON = 'native_app_settings.json'
SCORM_SETTINGS_JSON = 'scorm_settings.json'
APP_HTML_PARAMS_JSON = 'html_params.json'

LOGIC_XML = 'visual_logic.xml'
LOGIC_JS = 'visual_logic.js'
PUZZLES_DIR = 'puzzles'
PLUGINS_DIR = 'plugins'
LIBRARY_XML = 'my_library.xml'
BACKUP_LIBRARY_DIR = 'library_backup'
PUZZLE_PLUGIN_INIT_FILE_NAME = 'init.plug'
PUZZLE_PLUGIN_BLOCK_FILE_EXT = 'block'

APP_DATA_DIR = 'v3d_app_data'
BACKUP_PUZZLES_DIR = 'puzzles_backup'
BACKUP_UPDATE_DIR = 'update_backup'

PREVIEW_TMP_DIR = 'preview'
DIST_TMP_DIR = 'dist'

CDN_HOST = 'cdn.soft8soft.com'
CDN_URL = 'https://' + CDN_HOST + '/'
ELECTRON_RELEASE = 'v37.2.3'

APP_SRC_IGNORE = [
    APP_DATA_DIR,
    APP_DATA_DIR + '/*',
    APP_DATA_DIR + '/*/*', # including subfolders
    'visual_logic.xml',
    '*.blend',
    '*.blend1',
    '*.blend2',
    '*.max',
    '*.ma',
    '*.mb',
    '*.mat',    # max material file
    '*.mel',    # e.g. workspace.mel
    '*.tx',     # arnold texture
    '.mayaSwatches',
    '.mayaSwatches/*'
]

SERVER_MAX_BODY_SIZE = 2*1024*1024*1024 # 2GB

WWW_HOST = 'www.soft8soft.com'

AUTH_HOST = 'auth.soft8soft.com'
AUTH_PORT = 443

SHORTENER_HOST = 'v3d.net'
SHORTENER_PORT = 443
SHORTENER_API_KEY = '400263e02acad52c1d93308c5adde0'

ALLOWED_NET_REQUESTS = ['status', 'upload', 'download', 'delete', 'progress', 'cancel']

ACCESS_DENIED_MSG = '<span class="red">Access is denied. Make sure that you have permissions to write into Verge3D applications folder.</span>'

BAD_ZIP_MSG = 'Bad application ZIP file1'

MODULES = [
    'v3d.js',
    'opentype.js',
    'basis_transcoder.js',
    'basis_transcoder.wasm',
    'ammo.wasm.js',
    'ammo.wasm.wasm'
]

MANUAL_URL_DEFAULT = 'https://' + WWW_HOST + '/docs/manual/en/index.html'

APP_WORKER_NAME = 'v3d_pwa_cache.js'
APP_WORKER_UNREG_NAME = 'v3d_pwa_cache_unreg.js'

DEBUG = False

http_client_import = None

log = logging.getLogger('V3D-AM')
log.setLevel(logging.INFO)

class CustomLogFormatter(logging.Formatter):
    def format(self, record):
        record.name = record.name.split('.')[0].upper()
        return logging.Formatter.format(self, record)

class NothingToTransferException(Exception):
    pass

class TransferCancelledException(Exception):
    pass

class UpdateAppException(Exception):
    pass

class AppManagerServer:

    def __init__(self, flavor):

        self.flavor = flavor

        self.needRestart = False
        self.ioloop = None
        self.asyncioLoop = None
        self.serverTempDir = None
        self.runtimeReleaseVersion = ''

        self.ports = PORTS[flavor]

        self.initLogger()

        mimetypes.add_type('application/wasm', '.wasm')
        mimetypes.add_type('model/vnd.usdz+zip', '.usdz')

        mimetypes.add_type('application/javascript', '.js')

        mimetypes.add_type('image/svg+xml', '.svg')

    def initLogger(self):
        self.logMemStream = io.StringIO()

        for logH in [logging.StreamHandler(sys.stdout), logging.StreamHandler(self.logMemStream)]:
            logH.setFormatter(CustomLogFormatter('%(name)s-%(levelname)s: %(message)s'))
            log.addHandler(logH)
            tornado.log.access_log.addHandler(logH)
            tornado.log.app_log.addHandler(logH)
            tornado.log.gen_log.addHandler(logH)

    def writeOutStream(self, s):
        if sys.stdout:
            sys.stdout.write(s)
        self.logMemStream.write(s)

    def getProductName(self):
        return 'Verge3D {}'.format('Ultimate' if self.flavor == 'ALL' else 'for ' + self.flavor.title())

    def copyTreeMerge(self, topSrcDir, topDstDir, ignoredPatterns=[]):

        def isIgnored(name):
            for pattern in ignoredPatterns:
                if pathlib.Path(name).match(pattern):
                    return True
            return False

        for srcDir, dirs, files in os.walk(topSrcDir):
            dstDir = srcDir.replace(str(topSrcDir), str(topDstDir), 1)

            if isIgnored(dstDir):
                continue

            if not os.path.exists(dstDir):
                os.makedirs(dstDir)

            for file_ in files:
                srcFile = os.path.join(srcDir, file_)
                dstFile = os.path.join(dstDir, file_)

                if isIgnored(dstFile):
                    continue

                if os.path.exists(dstFile):
                    os.remove(dstFile)

                shutil.copy2(srcFile, dstDir)

    def getLocalIP(self):
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        try:
            s.connect(('10.255.255.255', 1))
            IP = s.getsockname()[0]
        except:
            IP = ''
        finally:
            s.close()

        return IP

    def runPython(self, args):
        pythonPath = sys.executable

        if os.name == 'nt':
            si = subprocess.STARTUPINFO()
            si.dwFlags = subprocess.STARTF_USESHOWWINDOW
            si.wShowWindow = subprocess.SW_HIDE
            retStr = subprocess.check_output([pythonPath] + args, startupinfo=si)
        else:
            retStr = subprocess.check_output([pythonPath] + args)

        return retStr

    def getConfigDir(self):

        productName = self.getProductName()
        system = platform.system()

        if system == 'Windows':
            configDir = pathlib.Path.home() / 'AppData' / 'Roaming' / 'Soft8Soft' / productName
        elif system == 'Darwin':
            configDir = pathlib.Path.home() / 'Library' / 'Application Support' / productName
        else:
            configDir = pathlib.Path.home() / '.config' / productName.lower().replace(' ', '_')

        configDir.mkdir(parents=True, exist_ok=True)

        return configDir

    def getDocumentsDir(self):
        system = platform.system()

        if system == 'Windows':
            home = pathlib.Path.home()
            if home.name == 'Documents':
                docDir = home
            else:
                docDir = home / 'Documents'
        elif system == 'Darwin':
            docDir = pathlib.Path.home() / 'Documents'
        else:
            docDir = pathlib.Path.home() / 'Documents'

        return docDir

    def loadSettings(self):
        self.amSettings = {}

        try:
            with open(self.getConfigDir() / SETTINGS_JSON, 'r', encoding='utf-8') as f:
                self.amSettings = json.load(f)
        except OSError:
            pass
        except json.decoder.JSONDecodeError:
            log.error('Settings decoding error')

        defAppsDir = '' if DEBUG else (self.getDocumentsDir() / 'verge3d_apps')

        self.amSettings.setdefault('extAppsDirectory', defAppsDir)
        self.amSettings.setdefault('checkForUpdates', True)

        self.amSettings.setdefault('theme', 'light')
        self.amSettings.setdefault('manualURL', MANUAL_URL_DEFAULT)
        self.amSettings.setdefault('simplifiedMobileUI', True)

        self.amSettings.setdefault('uploadSources', False) # network publishing
        self.amSettings.setdefault('exportSources', True)  # zip export
        self.amSettings.setdefault('useLinkShortener', True)
        self.amSettings.setdefault('externalInterface', False)
        self.amSettings.setdefault('cacheMaxAge', 15) # minutes

        self.amSettings.setdefault('proxyHost', '')
        self.amSettings.setdefault('proxyPort', 443)
        self.amSettings.setdefault('proxyUser', '')
        self.amSettings.setdefault('proxyPass', '')

        self.amSettings.setdefault('appTemplates', [
            {
                "name": "Standard Light",
                "description": "Create a generic Puzzles-enabled app suitable for most users (light theme)"
            },
            {
                "name": "Standard Dark",
                "description": "Create a generic Puzzles-enabled app suitable for most users (dark theme)"
            },
            {
                "name": "Blank Scene",
                "description": "Create a blank Puzzles-enabled app"
            },
            {
                "name": "Code-Based",
                "description": "Create a starter set of HTML/CSS/JavaScript files for use by web developers"
            }
        ])
        self.amSettings.setdefault('enablePerformanceMode', False)

        self.amSettings.setdefault('licenseKey', '')
        self.amSettings.setdefault('licenseKeyVersion', '')

        if 'externalAddress' in self.amSettings:
            del self.amSettings['externalAddress']

        if self.amSettings['externalInterface']:
            ip = self.getLocalIP()
            if ip:
                self.amSettings['externalAddress'] = 'http://{0}:{1}/'.format(ip, self.ports[0]);

        if self.amSettings['manualURL'] == '':
            self.amSettings['manualURL'] = MANUAL_URL_DEFAULT

        self.amSettings.setdefault('newSettings', True)
        self.amSettings.setdefault('lastReleaseVersion', '')

    def saveSettings(self, saveBySplash=False):

        if saveBySplash:
            if self.amSettings['extAppsDirectory'] != '':
                appsDir = pathlib.Path(self.amSettings['extAppsDirectory'])
                appsDir.mkdir(parents=True, exist_ok=True)

            self.amSettings['newSettings'] = False

            licenseInfo = self.getLicenseInfo()
            self.amSettings['lastReleaseVersion'] = licenseInfo['releaseVersion']

        with open(self.getConfigDir() / SETTINGS_JSON, 'w', encoding='utf-8') as f:
            json.dump(self.amSettings, f, sort_keys=True, indent=4, separators=(', ', ': '), ensure_ascii=False)
            log.info('App manager{}settings saved'.format(' splash ' if saveBySplash else ' '))

    def handleVersionUpdate(self):
        licenseInfo = self.getLicenseInfo()

        log.info('Version: {} ({})'.format(licenseInfo['releaseVersion'], 'DEBUG' if DEBUG else 'RELEASE'))
        log.info('License: {} {}'.format(licenseInfo['type'].title(),
                 '(expires {})'.format(licenseInfo['validUntil'].strftime('%d.%m.%Y')) if licenseInfo['validUntil'] else ''))
        log.info('Maintenance: {}'.format('yes' if licenseInfo['maintenance'] else 'no'))

        self.runtimeReleaseVersion = licenseInfo['releaseVersion']

        if not DEBUG and licenseInfo['maintenance'] and licenseInfo['releaseVersion'] != self.amSettings['licenseKeyVersion']:
            log.info('Updating licensing information')

            self.activateEngine(self.amSettings['licenseKey'])

            self.amSettings['licenseKeyVersion'] = licenseInfo['releaseVersion']
            self.saveSettings()

    def activateEngine(self, key):
        rootDir = self.getRootDir()

        if not DEBUG:
            engineDir = self.getConfigDir()

            for file in (rootDir / 'build').glob('v3d*'):
                shutil.copy2(file, engineDir)
        else:
            engineDir = rootDir / 'build'

        for v3dPaths in [self.getExtAppsDir(), engineDir]:
            for v3dPath in v3dPaths.rglob('v3d*.js'):
                path = str(v3dPath)
                try:
                    keymanager.activate(path, key)
                except UnicodeDecodeError:
                    log.error('Failed to activate corrupted engine {}'.format(path))

    def pathToUrl(self, path, quote=True, replaceBackslash=True, makeAbs=False):
        path = str(path)

        if replaceBackslash:
            path = path.replace('\\', '/')
        if quote:
            path = urllib.parse.quote(path, safe='/:')
        if makeAbs and path[0] != '/':
            path = '/' + path
        return path

    def replaceAmp(self, url):
        return url.replace('&', '&amp;')

    def urlBasename(self, url):
        path = urllib.parse.urlparse(url).path
        return os.path.basename(path)

    def argBool(self, argument):
        if argument.lower() in ['1', 'yes', 'y']:
            return True
        else:
            return False

    def getRootDir(self):
        return (pathlib.Path(BASE_DIR) / '..').resolve()

    def resolveURLPath(self, path):
        """Convert URL path to file system path"""

        if path.startswith(APPS_PREFIX):
            extAppsDir = self.getExtAppsDir()
            return (extAppsDir / path.replace(APPS_PREFIX, '')).resolve()
        else:
            return self.getRootDir() / path.lstrip('/')

    def relativeToWalkUp(self, path, pathTo):
        if sys.version_info >= (3, 12):
            return path.relative_to(pathTo, walk_up=True)
        else:
            return pathlib.Path(os.path.relpath(pathlib.Path(path), pathlib.Path(pathTo)))

    async def runAsync(self, func, *args, **kwargs):
        """Convert sync function to awaitable async function and run it"""
        executor = concurrent.futures.ThreadPoolExecutor(1)
        return await asyncio.wrap_future(executor.submit(func, *args, **kwargs))

    def genUpdateInfo(self, appDir, hcjFiles):
        """appDir is absolute, html/css/js files are app relative"""

        if not len(hcjFiles):
            return None

        modules = []

        needUpdate = False

        for m in MODULES:
            mDst = appDir / m

            if mDst.exists():
                modules.append(m)

                mSrc = self.getModulePath(m)
                if not filecmp.cmp(mSrc, mDst):
                    needUpdate = True

        if not needUpdate:
            return None

        files = []

        for f in hcjFiles:
            if f.name in MODULES:
                continue

            path = appDir / f
            if path.exists() and self.isTemplateBasedFile(path):
                files.append(f)

        if len(files):
            files.append(pathlib.Path('media'))

        info = {
            'modules': modules,
            'files': files
        }

        return info

    def getModulePath(self, module):
        cfgDir = self.getConfigDir()
        rootDir = self.getRootDir()

        if not DEBUG and (cfgDir / module).exists():
            return cfgDir / module
        else:
            return rootDir / 'build' / module

    def getExtAppsDir(self):
        extAppsDir = pathlib.Path(self.amSettings['extAppsDirectory'])
        if extAppsDir.is_absolute() and extAppsDir.is_dir():
            return extAppsDir
        else:
            return self.getRootDir() / 'applications'

    def findApp(self, name, pathOnly=False):
        appsDir = self.getExtAppsDir()
        name = pathlib.Path(name).name

        appDir = appsDir / name
        if appDir.is_dir():
            if pathOnly:
                return appDir
            else:
                return self.appInfo(appDir, appsDir)

        return None

    def isFileHidden(self, path):
        return (path.name.startswith('.') or
                path.match('AutoBackup*.max') or
                APP_DATA_DIR in path.parts or
                BACKUP_LIBRARY_DIR in path.parts)

    def findApps(self):
        apps = []

        appsDir = self.getExtAppsDir()

        for apppath in sorted(appsDir.iterdir()):
            if apppath.is_dir() and not self.isFileHidden(apppath):
                apps.append(self.appInfo(apppath, appsDir))

        return apps

    def listFilesRel(self, allPaths, pattern, relativePath):
        paths = []

        for p in allPaths:
            if p.match(pattern) and p.is_file() and not self.isFileHidden(p):
                pr = p.relative_to(relativePath)
                paths.append(pr)

        return sorted(paths)

    def appTitle(self, name):
        title = re.sub(TEST_APP_PREFIX, '', name) if DEBUG else name
        return title.replace('_', ' ').title()

    def appInfo(self, appDir, appsDir):
        appDirAppsRel = appDir.relative_to(appsDir)

        appFiles = filter(lambda p: p.is_file(), appDir.rglob('*'))
        appFiles = [p for p in appFiles if 'node_modules' not in p.parts]

        logicXML = appDirAppsRel / LOGIC_XML

        if not (appsDir / logicXML).exists():
            logicXMLs = self.listFilesRel(appFiles, 'visual_logic*.xml', appsDir)
            if logicXMLs:
                logicXML = logicXMLs[0]

        return {
            'name' : appDir.name,
            'title' : self.appTitle(appDir.name),
            'appDir': appDir.resolve(),
            'appsDir': appsDir.resolve(),
            'path' : str(appDirAppsRel),
            'html' : self.listFilesRel(appFiles, '*.html', appsDir),
            'blend' : self.listFilesRel(appFiles, '*.blend', appsDir),
            'max' : self.listFilesRel(appFiles, '*.max', appsDir),
            'maya' : (self.listFilesRel(appFiles, '*.ma', appsDir) +
                      self.listFilesRel(appFiles, '*.mb', appsDir)),
            'gltf' : (self.listFilesRel(appFiles, '*.gltf', appsDir) +
                      self.listFilesRel(appFiles, '*.glb', appsDir)),
            'logicXML': logicXML,
            'updateInfo': self.genUpdateInfo(appDir,
                    self.listFilesRel(appFiles, '*.html', appDir) +
                    self.listFilesRel(appFiles, '*.js', appDir) +
                    self.listFilesRel(appFiles, '*.css', appDir))
        }

    def isPuzzlesBasedFile(self, path):
        """absolute path"""

        with open(path, 'r', encoding='latin_1') as f:
            content = f.read()
            if '__V3D_PUZZLES__' in content:
                return True

        return False

    def isTemplateBasedFile(self, path):
        """absolute path"""

        if path.is_file():
            with open(path, 'r', encoding='latin_1') as f:
                content = f.read()
                if '__V3D_TEMPLATE__' in content:
                    return True

        return False

    def getProxySettings(self):
        """
        Only proxyHost needed to be tested for proxy, and proxyUser for authenticated proxy
        """
        proxyHost = self.amSettings['proxyHost'].strip().removeprefix('http://').removeprefix('https://')
        proxyPort = self.amSettings['proxyPort']
        proxyUser = self.amSettings['proxyUser'].strip()
        proxyPass = self.amSettings['proxyPass'].strip()

        if proxyHost and proxyPort:
            if proxyUser and proxyPass:
                return proxyHost, proxyPort, proxyUser, proxyPass
            else:
                return proxyHost, proxyPort, None, None
        else:
            return None, None, None, None

    def initHTTPSConnection(self, host, port, timeout=10):
        global http_client_import

        context = ssl.create_default_context()

        context.check_hostname = False
        context.verify_mode = ssl.CERT_NONE

        if http_client_import == None:
            import http.client
            http_client_import = http.client

        proxyHost, proxyPort, proxyUser, proxyPass = self.getProxySettings()
        if proxyHost:
            conn = http_client_import.HTTPSConnection(proxyHost, proxyPort, timeout=timeout, context=context)

            if proxyUser:
                auth = base64.b64encode((proxyUser + ':' + proxyPass).encode()).decode()
                headers = { 'Proxy-Authorization': 'Basic ' + auth }
            else:
                headers = None
            conn.set_tunnel(host, port, headers)
        else:
            conn = http_client_import.HTTPSConnection(host, port, timeout=timeout, context=context)

        return conn

    async def downloadFileAsync(self, url, filename, chunkSize=64*1024):
        """
        Asynchronously download a file using HTTPSConnection wrapped in a thread
        """
        parsedURL = urllib.parse.urlparse(url)

        def syncDownload():
            conn = self.initHTTPSConnection(parsedURL.hostname, 443, 60)
            path = parsedURL.path

            conn.request('GET', path)
            resp = conn.getresponse()
            if resp.status != 200:
                log.error('GET {} {} {}'.format(path, resp.status, resp.reason))
                return

            with open(filename, 'wb') as f:
                while True:
                    chunk = resp.read(chunkSize)
                    if not chunk:
                        break
                    f.write(chunk)

            conn.close()

        await asyncio.to_thread(syncDownload)

    def getAnonymousTrialKey(self):

        log.info('Receiving anonymous trial license key')

        conn = self.initHTTPSConnection(AUTH_HOST, AUTH_PORT)

        try:
            conn.request('GET', '/get_anon_key')
            resp = conn.getresponse()
        except:
            log.error('GET /get_anon_key Connection refused')
            return None

        if resp.status == 200:
            key = resp.read().decode()

            self.amSettings['licenseKey'] = key
            self.amSettings['licenseKeyVersion'] = self.getLicenseInfo()['releaseVersion']

            self.saveSettings()
            log.info('Key: ' + key)
            return key
        else:
            log.error('GET /get_anon_key {} {}'.format(resp.status, resp.reason))
            return None

    def getKeyTypeChar(self, key):
        return key[len(key)-14]

    def checkKeyModPackage(self, key):
        typechar = self.getKeyTypeChar(key)

        if self.flavor == 'BLENDER' and typechar in ['1', '2', '3']:
            return True
        elif self.flavor == 'MAX' and typechar in ['4', '5', '6']:
            return True
        elif self.flavor == 'MAYA' and typechar in ['7', '8', '9']:
            return True
        elif typechar == 'a':
            return True
        else:
            return False

    def getLicenseInfo(self, key=None):
        info = {
            'type': 'TRIAL',
            'flavor': '',
            'keyExists': False,
            'keyHash': None,
            'validUntil': None,
            'maintenance': False,
            'ineligibleVersion': False,
            'releaseVersion': '',
            'releaseDate': None,
            'releaseIsPreview': False
        }

        try:
            with open(pathlib.Path(BASE_DIR) / '..' / 'package.json', 'r', encoding='utf-8') as f:
                package = json.load(f)
                info['releaseVersion'] = package['version']
                releaseDate = datetime.datetime.strptime(package['date'], "%Y-%m-%d")
                releaseDate = releaseDate.replace(tzinfo=datetime.timezone.utc)
                info['releaseDate'] = releaseDate
                info['releaseIsPreview'] = True if 'pre' in package['version'] else False
        except OSError:
            pass

        if key == None:
            key = self.amSettings['licenseKey'].strip()

        if key and keymanager.checkKey(key):
            info['keyExists'] = True

            h = hashlib.sha1()
            h.update(key.encode())
            info['keyHash'] = h.hexdigest()

            typechar = self.getKeyTypeChar(key)
            delta = 0

            if typechar == '0':
                info['type'] = 'TRIAL'
                delta = datetime.timedelta(days=30)
            else:
                if typechar == '1':
                    info['type'] = 'FREELANCE'
                    info['flavor'] = 'BLENDER'
                if typechar == '2':
                    info['type'] = 'TEAM'
                    info['flavor'] = 'BLENDER'
                if typechar == '3':
                    info['type'] = 'ENTERPRISE'
                    info['flavor'] = 'BLENDER'
                if typechar == '4':
                    info['type'] = 'FREELANCE'
                    info['flavor'] = 'MAX'
                if typechar == '5':
                    info['type'] = 'TEAM'
                    info['flavor'] = 'MAX'
                if typechar == '6':
                    info['type'] = 'ENTERPRISE'
                    info['flavor'] = 'MAX'
                if typechar == '7':
                    info['type'] = 'FREELANCE'
                    info['flavor'] = 'MAYA'
                if typechar == '8':
                    info['type'] = 'TEAM'
                    info['flavor'] = 'MAYA'
                if typechar == '9':
                    info['type'] = 'ENTERPRISE'
                    info['flavor'] = 'MAYA'
                if typechar == 'a':
                    info['type'] = 'ULTIMATE'
                    info['flavor'] = 'ALL'
                delta = datetime.timedelta(days=365)

                info['maintenance'] = True

            unix = int(key[len(key)-13 : len(key)-4], 16)

            dt = datetime.datetime.fromtimestamp(unix, tz=datetime.timezone.utc)

            info['validUntil'] = dt + delta

            if info['validUntil'] < info['releaseDate']:
                info['maintenance'] = False
                info['ineligibleVersion'] = True

            now = datetime.datetime.now(tz=datetime.timezone.utc)
            if now > info['validUntil']:
                info['maintenance'] = False

        return info

    def shortenLinks(self, links):
        shortenerData = {'links': []}

        for link in links:
            shortenerData['links'].append({'url': link})

        conn = self.initHTTPSConnection(SHORTENER_HOST, SHORTENER_PORT)

        params = json.dumps({
            'key': SHORTENER_API_KEY,
            'data': json.dumps(shortenerData)
        })

        headers = {'Content-type': 'application/json'}

        try:
            conn.request('POST', '/api/v2/action/shorten_bulk', params, headers)
            resp = conn.getresponse()
        except:
            log.error('POST /api/v2/action/shorten_bulk Connection refused')
            return []

        if resp.status == 200:
            respData = json.loads(resp.read().decode())
            return respData['result']['shortened_links']
        else:
            log.error('POST /api/v2/action/shorten_bulk {} {}'.format(resp.status, resp.reason))
            return []

    def copyTemplateTree(self, src, dst, appName, modPackSubDir):
        names = os.listdir(src)
        errors = []

        if not os.path.exists(dst):
            os.mkdir(dst)

        for name in names:
            srcname = os.path.join(src, name)

            try:
                if os.path.isdir(srcname):
                    if name not in ['blender', 'max', 'maya']:
                        self.copyTemplateTree(srcname, os.path.join(dst, name), appName, modPackSubDir)
                    elif name == modPackSubDir:
                        self.copyTemplateTree(srcname, dst, appName, modPackSubDir)
                else:
                    dstname = os.path.join(dst, name.replace('template', appName))
                    shutil.copy2(srcname, dstname)
            except OSError as why:
                errors.append((srcname, os.path.join(dst, name), str(why)))
            except shutil.Error as err:
                errors.extend(err.args[0])

        if errors:
            raise shutil.Error(errors)

    def backupFile(self, path, backupDir):
        path = pathlib.Path(path)

        if not path.is_file():
            return

        backupDir = pathlib.Path(backupDir)

        log.info('Performing backup: {}'.format(path.name))

        if not backupDir.exists():
            backupDir.mkdir(parents=True)

        now = datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S')
        shutil.copyfile(path, backupDir / (path.stem + '_' + now + path.suffix))

    def verifyTemplate(self, tplName):
        return (pathlib.Path(BASE_DIR) / 'templates' / tplName).exists()

    def copyAppTemplate(self, appName, appDir, tplName, modPackage, modules):

        appTplDir = pathlib.Path(BASE_DIR) / 'templates' / tplName
        if not appTplDir.exists():
            return

        self.copyTemplateTree(appTplDir, appDir, appName, modPackage.lower())

        for htmlPath in appDir.rglob('*.html'):
            self.replaceTemplateStrings(htmlPath, re.compile(r'template(_\w+)?(\.)'), appName + r'\1\2')

            version = self.getLicenseInfo()['releaseVersion']
            version = re.findall(r'\d*\.\d*\.?\d*', version)[0]
            metaCode = '<meta name="generator" content="Verge3D {0}">'.format(version)
            self.replaceTemplateStrings(htmlPath, '<!-- __V3D_META__ -->', metaCode)

            if DEBUG and re.match(TEST_APP_PREFIX, appDir.name):
                modules = []
                self.replaceTemplateStrings(htmlPath, '"v3d.js"', '"../../build/v3d.js"')

        for jsPath in appDir.rglob('*.js'):
            self.replaceTemplateStrings(jsPath, re.compile(r'template(_\w+)?(\.gltf)'), appName + r'\1\2')

        for gltfPath in appDir.rglob('*.gltf'):
            self.replaceTemplateStrings(gltfPath, re.compile(r'template(_\w+)?(\.bin)'), appName + r'\1\2')

        manifestPath = appDir / 'media' / 'manifest.json'
        if manifestPath.exists():
            self.replaceTemplateStrings(manifestPath, 'template.html', appName + '.html')

        for mod in modules:
            mSrc = self.getModulePath(mod)
            mDst = os.path.join(appDir, mod)
            shutil.copyfile(mSrc, mDst)
            shutil.copystat(mSrc, mDst)

        self.genTemplateMetadata(appDir, tplName)

    def genTemplateMetadata(self, appDir, tplName):

        tplData = {
            'name': tplName,
            'files': {}
        }

        for f in filter(lambda p: p.is_file(), appDir.rglob('*')):
            with open(f, 'rb') as fp:
                content = fp.read()

                h = hashlib.sha1()
                h.update(content)

                fileData = {
                    'hash': h.hexdigest()
                };

                if f.suffix in ['.html', '.css', '.js'] and f.name != 'v3d.js' and self.isTemplateBasedFile(f):
                    fileData['type'] = 'ASCII'
                    fileData['content'] = base64.b64encode(content).decode('utf-8')
                else:
                    fileData['type'] = 'BINARY'

                tplData['files'][str(f.relative_to(appDir))] = fileData

        appDataDir = appDir / APP_DATA_DIR

        if not appDataDir.exists():
            appDataDir.mkdir()

        with open(appDataDir / APP_TEMPLATE_JSON, 'w', encoding='utf-8') as f:
            json.dump(tplData, f, sort_keys=True, indent=4, separators=(', ', ': '), ensure_ascii=False)

    def replaceTemplateStrings(self, path, pattern, replacement=''):
        """
        A: Replace 'pattern' to 'replacement'
        B: Replace 'pattern' to 'replacement' (regexp version)
        C: Render template with 'pattern' substitution using Mako Template class
        """

        content = None

        with open(path, 'r', encoding='utf-8') as fin:
            content = fin.read()

        if type(pattern) == str:
            content = content.replace(pattern, replacement)
        elif type(pattern) == re.Pattern:
            content = pattern.sub(replacement, content)
        else:
            content = Template(content).render_unicode(**pattern)

        with open(path, 'w', encoding='utf-8') as fout:
            fout.write(content)

    def getS3Credentials(self):
        licenseInfo = self.getLicenseInfo()

        if not licenseInfo['keyExists'] and self.getAnonymousTrialKey():
            licenseInfo = self.getLicenseInfo()

        if not licenseInfo['keyExists']:
            return None

        conn = self.initHTTPSConnection(AUTH_HOST, AUTH_PORT)

        try:
            conn.request('GET', '/get_credentials?hash=%s' % licenseInfo['keyHash'])
            resp = conn.getresponse()
        except:
            log.error('GET /get_credentials Connection refused')
            return None

        if resp.status == 200:
            return json.loads(resp.read().decode())
        else:
            log.error('GET /get_credentials {} {}'.format(resp.status, resp.reason))
            return None

    def sortHTMLs(self, appName, files):
        """index files come first"""

        indices = []
        main = []
        other = []

        for f in files:
            if 'index.htm' in f.name:
                indices.append(f)
            elif self.checkMainFile(appName, f, files):
                main.append(f)
            else:
                other.append(f)

        return indices + main + other

    def checkMainFile(self, appName, file, files):
        for f in files:
            if (difflib.SequenceMatcher(None, f.name, appName).ratio() >
                    difflib.SequenceMatcher(None, file.name, appName).ratio()):
                return False

        return True

    def findMainFile(self, appName, files):
        mainFile = files[0]

        for f in files:
            if (difflib.SequenceMatcher(None, f.name, appName).ratio() >
                    difflib.SequenceMatcher(None, mainFile.name, appName).ratio()):
                mainFile = f

        return mainFile

    def findMainCSS(self, appName, appDir):
        cssFilesAll = list(appDir.rglob('*.css'))

        cssFiles = [f for f in cssFilesAll if self.isTemplateBasedFile(f)]
        if len(cssFiles) == 0:
            cssFiles = cssFilesAll

        if len(cssFiles) == 0:
            return None

        return self.findMainFile(appName, cssFiles)

    def findMainJS(self, appName, appDir):
        jsFilesAll = list(appDir.rglob('*.js'))

        jsFiles = [f for f in jsFilesAll if self.isTemplateBasedFile(f)]
        if len(jsFiles) == 0:
            jsFiles = jsFilesAll

        if len(jsFiles) == 0:
            return None

        return self.findMainFile(appName, jsFiles)

    def findManifest(self, appDir):
        manifestFiles = list(appDir.rglob('manifest.json'))
        if len(manifestFiles) == 0:
            return None
        return manifestFiles[0]

    def manageURL(self, app):
        return '/manage/?app={0}'.format(urllib.parse.quote(app))

    def getHTMLParams(self, appInfo, replaceVisualLogicExt=False):
        """
        NOTE: replaceVisualLogicExt needed to support experimental feature when passing logic=my_custom_logic.xml to HTML params file
        """
        params = {}

        try:
            with open(appInfo['appDir'] / APP_DATA_DIR / APP_HTML_PARAMS_JSON, 'r', encoding='utf-8') as f:
                params = json.load(f)
        except OSError:
            pass
        except json.decoder.JSONDecodeError:
            log.error('HTML params decoding error')

        for html, pars in params.items():
            for i in range(len(pars)):
                parsDict = urllib.parse.parse_qs(pars[i], keep_blank_values=True)

                if replaceVisualLogicExt and 'logic' in parsDict:
                    for j in range(len(parsDict['logic'])):
                        parsDict['logic'][j] = parsDict['logic'][j].replace('.xml', '.js')

                if 'kind' in parsDict:
                    label = parsDict['kind'][0]
                else:
                    label = str(i+1)

                pars[i] = {
                    'params': urllib.parse.urlencode(parsDict, doseq=True, quote_via=urllib.parse.quote),
                    'label': label
                }

        return params

    def genAppViewInfo(self, app):
        appViewInfo = {
            'name' : app['name'],
            'title' : app['title'],
            'url': APPS_PREFIX + self.pathToUrl(app['path']),
            'manageURL': self.manageURL(app['name']),
            'html': [],
            'gltf': [],
            'blend': [],
            'max': [],
            'maya': [],
            'puzzles': None,
            'needsUpdate': False
        }

        playerBasedHTMLs = []

        htmlParams = self.getHTMLParams(app, True)

        for html in self.sortHTMLs(app['name'], app['html']):
            if self.isPuzzlesBasedFile(app['appsDir'] / html):
                playerBasedHTMLs.append(html)

            if htmlParams:
                htmlRelToApp = self.pathToUrl((app['appsDir'] / html).relative_to(app['appDir']))
                if htmlRelToApp in htmlParams:
                    for item in htmlParams[htmlRelToApp]:
                        suffix = f' [{item["label"]}]'
                        appViewInfo['html'].append({'name': os.path.basename(html) + suffix,
                                                    'path': str(html),
                                                    'url': APPS_PREFIX + self.pathToUrl(html) + '?' + item['params']})
            else:
                appViewInfo['html'].append({'name': os.path.basename(html),
                                            'path': str(html),
                                            'url': APPS_PREFIX + self.pathToUrl(html)})

        for gltf in app['gltf']:
            appViewInfo['gltf'].append({'name': os.path.basename(gltf),
                                        'path': str(gltf),
                                        'url': '/player/player.html?load=..' + APPS_PREFIX + self.pathToUrl(gltf)})

        for blend in app['blend']:
            if len(playerBasedHTMLs):
                isMain = self.checkMainFile(app['name'], blend, app['blend'])
            else:
                isMain = False

            appViewInfo['blend'].append({'name': os.path.basename(blend),
                                         'path': str(blend),
                                         'url': APPS_PREFIX + self.pathToUrl(blend),
                                         'isMain': isMain })

        for maxx in app['max']:
            if len(playerBasedHTMLs):
                isMain = self.checkMainFile(app['name'], maxx, app['max'])
            else:
                isMain = False

            appViewInfo['max'].append({'name': os.path.basename(maxx),
                                       'path': str(maxx),
                                       'url': APPS_PREFIX + self.pathToUrl(maxx),
                                       'isMain': isMain })

        for maya in app['maya']:
            if len(playerBasedHTMLs):
                isMain = self.checkMainFile(app['name'], maya, app['maya'])
            else:
                isMain = False

            appViewInfo['maya'].append({'name': os.path.basename(maya),
                                        'path': str(maya),
                                        'url': APPS_PREFIX + self.pathToUrl(maya),
                                        'isMain': isMain })

        for assetType in ['blend', 'max', 'maya']:
            for i in range(len(appViewInfo[assetType])):
                asset = appViewInfo[assetType][i]
                if asset['isMain']:
                    appViewInfo[assetType].insert(0, appViewInfo[assetType].pop(i))
                    break

        if len(playerBasedHTMLs):
            mainHTML = self.findMainFile(app['name'], playerBasedHTMLs)

            logicRelToHTML = self.relativeToWalkUp(app['logicXML'], mainHTML.parent)

            htmlParams = self.getHTMLParams(app, False)
            if htmlParams:
                htmlRelToApp = self.pathToUrl((app['appsDir'] / mainHTML).relative_to(app['appDir']))

                if htmlRelToApp in htmlParams:
                    urls = []
                    labels = []

                    for item in htmlParams[htmlRelToApp]:
                        url = APPS_PREFIX + self.pathToUrl(mainHTML)
                        url += '?logic=' + self.pathToUrl(logicRelToHTML)
                        url += '&theme=' + self.amSettings['theme']
                        url += '&perf' if self.amSettings['enablePerformanceMode'] else ''
                        url += '&' + item['params']
                        urls.append(url)

                        labels.append(item['label'])
                    appViewInfo['puzzles'] = {'urls': urls, 'labels': labels}
            else:
                url = APPS_PREFIX + self.pathToUrl(mainHTML)
                url += '?logic=' + self.pathToUrl(logicRelToHTML)
                url += '&theme=' + self.amSettings['theme']
                url += '&perf' if self.amSettings['enablePerformanceMode'] else ''

                appViewInfo['puzzles'] = {'urls': [url]}

        if app['updateInfo']:
            appViewInfo['needsUpdate'] = True

        return appViewInfo

    def getManifestParam(self, appDir, param):
        manifest = self.findManifest(appDir)
        if manifest:
            tokens = appparse.getJSTokens(manifest)

            appTokens = self.AppTokens()
            tokenSrc = appTokens.getAppSettingToken(param, tokens)
            if tokenSrc:
                return appparse.tokValueGet(tokenSrc)

        return None

    def copyManifestMetadata(self, src, dst):

        appTokens = self.AppTokens()

        manifestTokensSrc = appparse.getJSTokens(src)
        manifestTokensDst = appparse.getJSTokens(dst)

        writeManifest = False

        for setting in appTokens.manifestSettingNames():
            tokenSrc = appTokens.getAppSettingToken(setting, manifestTokensSrc)
            tokenDst = appTokens.getAppSettingToken(setting, manifestTokensDst)

            if tokenSrc and tokenDst:
                appparse.tokValueSet(tokenDst, appparse.tokValueGet(tokenSrc))
                writeManifest = True

        if writeManifest:
            appparse.writeTokens(manifestTokensDst, dst)

    def copyHTMLMetadata(self, src, dst):

        appTokens = self.AppTokens()

        htmlTokensSrc = appparse.getHTMLTokens(src)
        htmlTokensDst = appparse.getHTMLTokens(dst)

        writeHTML = False

        for setting in appTokens.htmlSettingNames():
            tokenSrc = appTokens.getAppSettingToken(setting, htmlTokensSrc)
            tokenDst = appTokens.getAppSettingToken(setting, htmlTokensDst)

            if tokenSrc and tokenDst:
                appparse.tokValueSet(tokenDst, appparse.tokValueGet(tokenSrc))
                writeHTML = True

        if writeHTML:
            appparse.writeTokens(htmlTokensDst, dst)

    def genServiceWorker(self, appDir, action):
        '''Generate service worker for offline operation'''

        worker = appDir / APP_WORKER_NAME
        rootDir = self.getRootDir()

        workerTokens = []
        workerHash = uuid.uuid4().hex[0:10]

        if worker.exists():
            workerTokens = appparse.getJSTokens(worker)

            hashTokens = filterTokens(workerTokens, ['CONST', ('IDENT', 'CACHE_HASH'), 'EQUAL', 'STRING_LITERAL', 'SEMI'])
            if hashTokens:
                workerHash = appparse.tokValueGet(nthOfType(hashTokens, 'STRING_LITERAL', 0))

        elif action == 'UPDATE':
            action = 'ENABLE'

        if action == 'ENABLE':
            shutil.copy2(rootDir / 'manager' / 'dist' / APP_WORKER_NAME, worker)
            workerTokens = appparse.getJSTokens(worker)
        elif action == 'DISABLE':
            shutil.copy2(rootDir / 'manager' / 'dist' / APP_WORKER_UNREG_NAME, worker)
            workerTokens = appparse.getJSTokens(worker)

        if action == 'ENABLE' or action == 'UPDATE':

            newTokens = []

            for p in filter(lambda p: p.is_file(), appDir.rglob('*')):
                ignore = False

                for pattern in APP_SRC_IGNORE:
                    if p.match(pattern):
                        ignore = True
                        break

                if p.match(APP_WORKER_NAME):
                    ignore = True
                elif (p.match('*.gltf') or p.match('*.bin') or p.match('*.glb')) and p.with_suffix(p.suffix + '.xz').exists():
                    ignore = True

                if not ignore:
                    wPath = self.pathToUrl(str(p.relative_to(appDir)), quote=False)
                    newTokens.append(appparse.spaceToken('\n    '))
                    newTokens.append(appparse.customToken('STRING_LITERAL', '\'' + wPath + '\''))
                    newTokens.append(appparse.customToken('COMMA', ','))

            newTokens.append(appparse.spaceToken('\n')) # to keep ] on new line

            insPattern = filterTokens(workerTokens, [('IDENT', 'ASSETS'), 'EQUAL', 'LBRACKET', 'RBRACKET'],
                                      ['SPACE', 'STRING_LITERAL', 'COMMA'])
            if insPattern:
                lbrTok = appparse.nthOfType(insPattern, 'LBRACKET', 0)
                rbrTok = appparse.nthOfType(insPattern, 'RBRACKET', 0)

                lbrIdx = workerTokens.index(lbrTok)
                rbrIdx = workerTokens.index(rbrTok)

                del workerTokens[lbrIdx + 1 : rbrIdx]

                appparse.tokensInsert(workerTokens, newTokens, lbrTok)

        if action == 'UPDATE':
            verTokens = filterTokens(workerTokens, ['CONST', ('IDENT', 'CACHE_VERSION'), 'EQUAL', 'STRING_LITERAL', 'SEMI'])
            if verTokens:
                verTok = nthOfType(verTokens, 'STRING_LITERAL', 0)
                value = appparse.tokValueGet(verTok)
                value = re.sub(r'v(\d+)', lambda exp: 'v{}'.format(int(exp.group(1)) + 1), value)
                appparse.tokValueSet(verTok, value)
        else:
            hashTokens = filterTokens(workerTokens, ['CONST', ('IDENT', 'CACHE_HASH'), 'EQUAL', 'STRING_LITERAL', 'SEMI'])
            if hashTokens:
                appparse.tokValueSet(nthOfType(hashTokens, 'STRING_LITERAL', 0), workerHash)

        log.info('{}ing {} worker'.format(action.title()[:-1], os.path.basename(worker)))
        appparse.writeTokens(workerTokens, worker)

    def updateApp(self, appInfo, modules, files, modPackage):
        appDir = appInfo['appDir']

        appDataDir = appDir / APP_DATA_DIR

        if not appDataDir.exists():
            appDataDir.mkdir()

        tplData = None

        try:
            with open(appDataDir / APP_TEMPLATE_JSON, 'r', encoding='utf-8') as f:
                tplData = json.load(f)
        except OSError:
            pass

        if tplData and 'name' in tplData:
            templateName = tplData['name']
        else:
            templateName = 'Standard Light'

        if not self.verifyTemplate(templateName):
            raise UpdateAppException('Template not found: ' + templateName)

        templateAppName = appDir.name
        templateFiles = list(filter(lambda f: (appDir / f).is_file() and self.isTemplateBasedFile(appDir / f), files))
        if len(templateFiles):
            templateAppName = self.findMainFile(appDir.name, templateFiles).stem

        log.info('Updating application: {} ({})'.format(templateAppName, appDir))

        mergeConflicts = []

        with tempfile.TemporaryDirectory() as td:
            tmpAppDir = pathlib.Path(td) / templateAppName
            tmpAppDir.mkdir()

            self.copyAppTemplate(templateAppName, tmpAppDir, templateName, modPackage, modules)

            for m in modules:
                mSrc = tmpAppDir / m
                mDst = appDir / m

                if not mDst.exists() or not filecmp.cmp(mSrc, mDst):
                    log.info('Updating app module: {0}'.format(m))
                    shutil.copyfile(mSrc, mDst)

                shutil.copystat(mSrc, mDst)

            for f in files:

                if f.name == 'media':
                    mediaSrcDir = tmpAppDir / 'media'
                    mediaDstDir = appDir / 'media'

                    if not mediaDstDir.exists():
                        mediaDstDir.mkdir()

                    for item in os.listdir(mediaSrcDir):
                        src = mediaSrcDir / item
                        dst = mediaDstDir / item

                        if not dst.exists():
                            log.info('Copying media file: {0}'.format(item))
                            self.backupFile(dst, appDataDir / BACKUP_UPDATE_DIR)

                            if item == 'manifest.json' and dst.exists():
                                log.info('Copying manifest metadata')
                                self.copyManifestMetadata(dst, src)

                            shutil.copyfile(src, dst)

                else:
                    src = tmpAppDir / (tmpAppDir.name + f.suffix)
                    dst = appDir / f

                    if not dst.exists() or not filecmp.cmp(src, dst):
                        log.info('Updating app file: {0}'.format(f))

                        self.backupFile(dst, appDataDir / BACKUP_UPDATE_DIR)

                        if tplData and str(f) in tplData['files'] and tplData['files'][str(f)]['type'] == 'ASCII':
                            log.info('Trying 3-way merge... ')

                            tmpFile = tempfile.NamedTemporaryFile('wb', delete=False)
                            tmpFile.write(base64.b64decode(tplData['files'][str(f)]['content']))
                            tmpFile.close()

                            dstStr = self.runPython([os.path.join(BASE_DIR, 'lib', 'merge3.py'),
                                                     str(dst), tmpFile.name, str(src)])
                            if '<<<<<<<' not in dstStr.decode('utf-8'):
                                with open(dst, 'wb') as fm:
                                    fm.write(dstStr)
                                log.info('Merge success!')
                            else:
                                if os.path.splitext(src)[1] == '.html':
                                    log.info('Conflict detected, pulling HTML metadata and overwriting...')
                                    self.copyHTMLMetadata(dst, src)
                                else:
                                    log.warning('Merge conflict detected, overwriting...')
                                shutil.copyfile(src, dst)
                                mergeConflicts.append(dst.name)
                        else:
                            if tplData and str(f) not in tplData['files']:
                                log.warning('Updated file not found in app template metadata, check it for data loss!')

                            shutil.copyfile(src, dst)

            if len(files):
                shutil.copyfile(tmpAppDir / APP_DATA_DIR / APP_TEMPLATE_JSON, appDataDir / APP_TEMPLATE_JSON)

        return mergeConflicts

    class ResponseTemplates:
        def getJSContext(self):
            server = self.settings['server']

            licenseInfo = server.getLicenseInfo();

            jsContext = {
                'debug': DEBUG,
                'version': licenseInfo['releaseVersion'],
                'package': server.flavor.title(), # COMPAT: < 4.6, for cached js assets
                'flavor': server.flavor.title(),
                'releaseDate': str(licenseInfo['releaseDate'].date()),
                'checkForUpdates': server.amSettings['checkForUpdates'],
                'versionInfoURL': 'https://' + WWW_HOST + '/verge3d-' + server.flavor.lower() + '-latest.json'
            }

            return jsContext

        def renderTemplate(self, template, substitution):

            tplLookup = TemplateLookup(directories=[os.path.join(BASE_DIR, 'ui')],
                                       input_encoding='utf-8',
                                       output_encoding='utf-8',
                                       encoding_errors='replace')

            server = self.settings['server']
            tplOut = tplLookup.get_template(template).render_unicode(**substitution)

            return tplOut

        def writeError(self, message, dialog=True):
            log.error('{} (error shown to user)'.format(message))
            if dialog:
                self.write(self.renderTemplate('dialog_error.tpl', {
                    'message': message
                }))
            else:
                self.write(self.renderTemplate('error.tpl', {
                    'message': message,
                    'jsContext': self.getJSContext()
                }))

        def getReqModPack(self):
            port = int(self.request.host.split(':')[1])
            return PORT_TO_PACK[port]

    class AppTokens:

        def cssValueTokens(self):
            '''list of tokens used in property values'''
            return [
                'SPACE',
                'COMMENT',
                'COMMA',
                'HASH',
                'MINUS',
                'RPAREN',
                'EMS',
                'EXS',
                'LENGTH',
                'ANGLE',
                'TIME',
                'FREQ',
                'DIMENSION',
                'PERCENTAGE',
                'NUMBER',
                'URI',
                'FUNCTION',
                'HASH',
                'VARPREFIX',
            ]

        def cssDeclTokens(self, ignoredIdentifiers):
            '''everything between { ... }'''
            return [('IDENT', '^'+i) for i in ignoredIdentifiers] + ['COLON'] + self.cssValueTokens() + ['SEMI']

        def filterCSSRules(self, tokens, selector, revert=True):
            tokensAll = filterTokensAll(tokens, [('IDENT', selector), 'LBRACE', 'RBRACE'], self.cssDeclTokens([selector]))
            if revert:
                tokensAll.reverse()
            return tokensAll

        def htmlSettingNames(self):
            return [
                'title',
                'description',
                'favicon48',
                'favicon180',
                'openGraphTitle',
                'openGraphDescription',
                'openGraphImage',
                'twitterCard'
            ]

        def cssSettingNames(self):
            return [
                'annotationColor',
                'annotationBackColor',
                'annotationBorderColor',
                'annotationFont',
                'annotationDialogColor',
                'annotationDialogBackColor',
                'annotationDialogBorderColor',
                'preloaderImage',
                'preloaderImageWidth',
                'preloaderImageHeight',
                'preloaderDropShadowColor',
                'preloaderDropShadowWidth',
                'preloaderBarHeight',
                'preloaderBarCSSColor',
                'preloaderBarSolidColor',
                'preloaderBarGradientColor',
                'preloaderBarGradientColor2',
                'preloaderBarBorderColor',
                'preloaderBackColor',
                'fullscreenOpenImage',
                'fullscreenCloseImage',
                'moveForwardImage',
                'webglErrorImage',
            ]

        def jsSettingNames(self):
            return [
                'webglErrorMsg',
                'offlinePWA',
            ]

        def manifestSettingNames(self):
            return [
                'favicon192',
                'favicon512',
                'webAppName',
                'startURL', # currently not in UI, used for updating
            ]

        def genFallbackTokens(self, setting, allTokens):

            server = self.settings['server']

            if setting == 'webglErrorMsg':
                tokens = appparse.getJSTokens(server.getRootDir() / 'manager' / 'dist' / 'template_patches.js')

                newTokens = filterTokens(tokens, [('IDENT', 'ctxSettings'), 'DOT', ('IDENT', 'webglErrorMsg'),
                                                  'EQUAL', 'STRING_LITERAL', 'SEMI'])
                newTokens.insert(0, appparse.spaceToken('\n    '))

                insPattern = filterTokens(allTokens, ['CONST', ('IDENT', 'ctxSettings'), 'EQUAL', 'LBRACE', 'RBRACE', 'SEMI'])
                if insPattern:
                    appparse.tokensInsert(allTokens, newTokens, insPattern[-1])
                    return newTokens
                else:
                    return []

            elif setting == 'offlinePWA':
                tokens = appparse.getJSTokens(server.getRootDir() / 'manager' / 'dist' / 'template_patches.js')

                newTokens = filterTokens(tokens, ['IF', 'LPAREN', 'BOOLEAN_LITERAL', 'RPAREN',
                                                  ('IDENT', 'navigator'), 'DOT',
                                                  ('IDENT', 'serviceWorker'), 'DOT',
                                                  ('IDENT', 'register'), 'LPAREN', 'STRING_LITERAL', 'RPAREN', 'SEMI'])
                newTokens.insert(0, appparse.spaceToken('\n    '))

                insPattern = filterTokens(allTokens, [('IDENT', 'getPageParams'), 'LPAREN', 'RPAREN', 'SEMI'])
                if insPattern:
                    appparse.tokensInsert(allTokens, newTokens, insPattern[-1])
                    return newTokens
                else:
                    return []

            cssClass = None

            if setting == 'preloaderImage':
                cssClass = 'v3d-simple-preloader-logo'
            elif setting in ['preloaderImageWidth', 'preloaderImageHeight']:
                cssClass = 'v3d-simple-preloader-container'
            elif setting in ['preloaderBarHeight', 'preloaderBarCSSColor', 'preloaderBarBorderColor']:
                cssClass = 'v3d-simple-preloader-bar'
            elif setting == 'preloaderBackColor':
                cssClass = 'v3d-simple-preloader-background'
            elif setting == 'moveForwardImage':
                cssClass = 'v3d-mobile-forward'
            elif setting == 'webglErrorImage':
                cssClass = 'v3d-webgl-error-image'
            elif setting in ['annotationColor', 'annotationBackColor', 'annotationBorderColor', 'annotationFont']:
                cssClass = 'v3d-annotation'
            elif setting in ['annotationDialogColor', 'annotationDialogBackColor', 'annotationDialogBorderColor']:
                cssClass = 'v3d-annotation-dialog'

            if cssClass:
                tokens = appparse.getCSSTokens(server.getRootDir() / 'manager' / 'dist' / 'template_patches.css')

                newTokens = filterTokens(tokens, ['DOT', ('IDENT', cssClass), 'LBRACE', 'RBRACE'],
                                         self.cssDeclTokens([cssClass]))
                newTokens.insert(0, appparse.spaceToken('\n\n'))

                appparse.tokensInsert(allTokens, newTokens)
                return newTokens
            else:
                return []

        def getAppSettingToken(self, setting, tokens):

            if setting == 'title':

                titleTokens = filterTokens(tokens, ['TAG_OPEN', ('TAG_NAME', 'title'), 'TAG_CLOSE',
                                                    'TAG_OPEN', 'TAG_SLASH', ('TAG_NAME', 'title'), 'TAG_CLOSE'])
                if titleTokens:
                    appparse.tokensInsert(tokens, appparse.customToken('HTML_TEXT', ''),
                                          nthOfType(titleTokens, 'TAG_CLOSE', 0))

                titleTokens = filterTokens(tokens, ['TAG_OPEN', ('TAG_NAME', 'title'), 'TAG_CLOSE',
                                                    'HTML_TEXT',
                                                    'TAG_OPEN', 'TAG_SLASH', ('TAG_NAME', 'title'), 'TAG_CLOSE'])
                if titleTokens:
                    return nthOfType(titleTokens, 'HTML_TEXT', 0)

            elif setting == 'description':

                metaTokens = filterTokens(tokens, ['TAG_OPEN', ('TAG_NAME', 'meta'),
                                                  ('TAG_NAME', 'content'), 'TAG_EQUALS', 'ATTRIBUTE',
                                                  ('TAG_NAME', 'name'), 'TAG_EQUALS', ('ATTRIBUTE', 'description'),
                                                   'TAG_CLOSE'])

                if not metaTokens:
                    metaTokens = filterTokens(tokens, ['TAG_OPEN', ('TAG_NAME', 'meta'),
                                                      ('TAG_NAME', 'name'), 'TAG_EQUALS', ('ATTRIBUTE', 'description'),
                                                      ('TAG_NAME', 'content'), 'TAG_EQUALS', 'ATTRIBUTE',
                                                       'TAG_CLOSE'])

                if metaTokens:
                    return nthOfType(filterTokens(metaTokens, [('TAG_NAME', 'content'), 'TAG_EQUALS', 'ATTRIBUTE']),
                                     'ATTRIBUTE', 0)

            elif setting == 'favicon48':

                linkTokens = filterTokens(tokens, ['TAG_OPEN', ('TAG_NAME', 'link'),
                                                  ('TAG_NAME', 'rel'), 'TAG_EQUALS', ('ATTRIBUTE', 'icon'),
                                                  ('TAG_NAME', 'type'), 'TAG_EQUALS', ('ATTRIBUTE', 'image/png'),
                                                  ('TAG_NAME', 'sizes'), 'TAG_EQUALS', ('ATTRIBUTE', '48x48'),
                                                  ('TAG_NAME', 'href'), 'TAG_EQUALS', 'ATTRIBUTE',
                                                   'TAG_CLOSE'])
                if linkTokens:
                    return nthOfType(filterTokens(linkTokens, [('TAG_NAME', 'href'), 'TAG_EQUALS', 'ATTRIBUTE']),
                                     'ATTRIBUTE', 0)

            elif setting == 'favicon180':

                linkTokens = filterTokens(tokens, ['TAG_OPEN', ('TAG_NAME', 'link'),
                                                  ('TAG_NAME', 'rel'), 'TAG_EQUALS', ('ATTRIBUTE', 'apple-touch-icon'),
                                                  ('TAG_NAME', 'sizes'), 'TAG_EQUALS', ('ATTRIBUTE', '180x180'),
                                                  ('TAG_NAME', 'href'), 'TAG_EQUALS', 'ATTRIBUTE',
                                                   'TAG_CLOSE'])
                if linkTokens:
                    return nthOfType(filterTokens(linkTokens, [('TAG_NAME', 'href'), 'TAG_EQUALS', 'ATTRIBUTE']),
                                     'ATTRIBUTE', 0)

            elif setting == 'openGraphTitle':

                metaTokens = filterTokens(tokens, ['TAG_OPEN', ('TAG_NAME', 'meta'),
                                                  ('TAG_NAME', 'property'), 'TAG_EQUALS', ('ATTRIBUTE', 'og:title'),
                                                  ('TAG_NAME', 'content'), 'TAG_EQUALS', 'ATTRIBUTE',
                                                   'TAG_CLOSE'])

                if not metaTokens:
                    metaTokens = filterTokens(tokens, ['TAG_OPEN', ('TAG_NAME', 'meta'),
                                                      ('TAG_NAME', 'content'), 'TAG_EQUALS', 'ATTRIBUTE',
                                                      ('TAG_NAME', 'property'), 'TAG_EQUALS', ('ATTRIBUTE', 'og:title'),
                                                       'TAG_CLOSE'])

                if metaTokens:
                    return nthOfType(filterTokens(metaTokens, [('TAG_NAME', 'content'), 'TAG_EQUALS', 'ATTRIBUTE']),
                                     'ATTRIBUTE', 0)

            elif setting == 'openGraphDescription':

                metaTokens = filterTokens(tokens, ['TAG_OPEN', ('TAG_NAME', 'meta'),
                                                  ('TAG_NAME', 'property'), 'TAG_EQUALS', ('ATTRIBUTE', 'og:description'),
                                                  ('TAG_NAME', 'content'), 'TAG_EQUALS', 'ATTRIBUTE',
                                                   'TAG_CLOSE'])

                if not metaTokens:
                    metaTokens = filterTokens(tokens, ['TAG_OPEN', ('TAG_NAME', 'meta'),
                                                      ('TAG_NAME', 'content'), 'TAG_EQUALS', 'ATTRIBUTE',
                                                      ('TAG_NAME', 'property'), 'TAG_EQUALS', ('ATTRIBUTE', 'og:description'),
                                                       'TAG_CLOSE'])

                if metaTokens:
                    return nthOfType(filterTokens(metaTokens, [('TAG_NAME', 'content'), 'TAG_EQUALS', 'ATTRIBUTE']),
                                     'ATTRIBUTE', 0)

            elif setting == 'openGraphImage':

                metaTokens = filterTokens(tokens, ['TAG_OPEN', ('TAG_NAME', 'meta'),
                                                  ('TAG_NAME', 'property'), 'TAG_EQUALS', ('ATTRIBUTE', 'og:image'),
                                                  ('TAG_NAME', 'content'), 'TAG_EQUALS', 'ATTRIBUTE',
                                                   'TAG_CLOSE'])

                if not metaTokens:
                    metaTokens = filterTokens(tokens, ['TAG_OPEN', ('TAG_NAME', 'meta'),
                                                      ('TAG_NAME', 'content'), 'TAG_EQUALS', 'ATTRIBUTE',
                                                      ('TAG_NAME', 'property'), 'TAG_EQUALS', ('ATTRIBUTE', 'og:image'),
                                                       'TAG_CLOSE'])

                if metaTokens:
                    return nthOfType(filterTokens(metaTokens, [('TAG_NAME', 'content'), 'TAG_EQUALS', 'ATTRIBUTE']),
                                     'ATTRIBUTE', 0)

            elif setting == 'twitterCard':

                metaTokens = filterTokens(tokens, ['TAG_OPEN', ('TAG_NAME', 'meta'),
                                                  ('TAG_NAME', 'name'), 'TAG_EQUALS', ('ATTRIBUTE', 'twitter:card'),
                                                  ('TAG_NAME', 'content'), 'TAG_EQUALS', 'ATTRIBUTE',
                                                   'TAG_CLOSE'])

                if not metaTokens:
                    metaTokens = filterTokens(tokens, ['TAG_OPEN', ('TAG_NAME', 'meta'),
                                                      ('TAG_NAME', 'content'), 'TAG_EQUALS', 'ATTRIBUTE',
                                                      ('TAG_NAME', 'name'), 'TAG_EQUALS', ('ATTRIBUTE', 'twitter:card'),
                                                       'TAG_CLOSE'])

                if metaTokens:
                    return nthOfType(filterTokens(metaTokens, [('TAG_NAME', 'content'), 'TAG_EQUALS', 'ATTRIBUTE']),
                                     'ATTRIBUTE', 0)

            elif setting == 'annotationColor':
                anoTokens = filterTokens(tokens, [('IDENT', 'v3d-annotation'), 'LBRACE', 'RBRACE'],
                                         self.cssDeclTokens(['v3d-annotation']))

                if anoTokens:
                    colTokens = filterTokens(anoTokens, [('IDENT', 'color'), 'COLON', 'HASH', 'SEMI'])
                    if colTokens:
                        return nthOfType(colTokens, 'HASH', 0)

            elif setting == 'annotationBackColor':
                anoTokens = filterTokens(tokens, [('IDENT', 'v3d-annotation'), 'LBRACE', 'RBRACE'],
                                         self.cssDeclTokens(['v3d-annotation']))

                if anoTokens:
                    bgTokens = filterTokens(anoTokens, [('IDENT', 'background'), 'COLON', 'FUNCTION',
                            'NUMBER', 'COMMA', 'NUMBER', 'COMMA', 'NUMBER', 'COMMA', 'NUMBER', 'RPAREN'])
                    if bgTokens:
                        return (nthOfType(bgTokens, 'NUMBER', 0),
                                nthOfType(bgTokens, 'NUMBER', 1),
                                nthOfType(bgTokens, 'NUMBER', 2),
                                nthOfType(bgTokens, 'NUMBER', 3))

            elif setting == 'annotationBorderColor':
                anoTokens = filterTokens(tokens, [('IDENT', 'v3d-annotation'), 'LBRACE', 'RBRACE'],
                                         self.cssDeclTokens(['v3d-annotation']))

                if anoTokens:
                    borTokens = filterTokens(anoTokens, [('IDENT', 'border'), 'COLON', 'LENGTH', 'IDENT', 'HASH', 'SEMI'])
                    if borTokens:
                        return nthOfType(borTokens, 'HASH', 0)

            elif setting == 'annotationFont':
                anoTokens = filterTokens(tokens, [('IDENT', 'v3d-annotation'), 'LBRACE', 'RBRACE'],
                                         self.cssDeclTokens(['v3d-annotation']))

                if anoTokens:
                    fntTokens = filterTokens(anoTokens, [('IDENT', 'font-size'), 'COLON', 'LENGTH'])
                    if fntTokens:
                        return nthOfType(fntTokens, 'LENGTH', 0)

            elif setting == 'annotationDialogColor':
                anoTokens = filterTokens(tokens, [('IDENT', 'v3d-annotation-dialog'), 'LBRACE', 'RBRACE'],
                                         self.cssDeclTokens(['v3d-annotation-dialog']))

                if anoTokens:
                    colTokens = filterTokens(anoTokens, [('IDENT', 'color'), 'COLON', 'HASH', 'SEMI'])
                    if colTokens:
                        return nthOfType(colTokens, 'HASH', 0)

            elif setting == 'annotationDialogBackColor':
                anoTokens = filterTokens(tokens, [('IDENT', 'v3d-annotation-dialog'), 'LBRACE', 'RBRACE'],
                                         self.cssDeclTokens(['v3d-annotation-dialog']))

                if anoTokens:
                    bgTokens = filterTokens(anoTokens, [('IDENT', 'background'), 'COLON', 'FUNCTION',
                            'NUMBER', 'COMMA', 'NUMBER', 'COMMA', 'NUMBER', 'COMMA', 'NUMBER', 'RPAREN'])
                    if bgTokens:
                        return (nthOfType(bgTokens, 'NUMBER', 0),
                                nthOfType(bgTokens, 'NUMBER', 1),
                                nthOfType(bgTokens, 'NUMBER', 2),
                                nthOfType(bgTokens, 'NUMBER', 3))

            elif setting == 'annotationDialogBorderColor':
                anoTokens = filterTokens(tokens, [('IDENT', 'v3d-annotation-dialog'), 'LBRACE', 'RBRACE'],
                                         self.cssDeclTokens(['v3d-annotation-dialog']))

                if anoTokens:
                    borTokens = filterTokens(anoTokens, [('IDENT', 'border'), 'COLON', 'LENGTH', 'IDENT', ('FUNCTION', 'rgba('),
                            'NUMBER', 'COMMA', 'NUMBER', 'COMMA', 'NUMBER', 'COMMA', 'NUMBER', 'RPAREN', 'SEMI'])
                    if borTokens:
                        return (nthOfType(borTokens, 'NUMBER', 0),
                                nthOfType(borTokens, 'NUMBER', 1),
                                nthOfType(borTokens, 'NUMBER', 2),
                                nthOfType(borTokens, 'NUMBER', 3))

            elif setting == 'preloaderImage':
                preTokens = filterTokens(tokens, [('IDENT', 'v3d-simple-preloader-logo'), 'LBRACE', 'RBRACE'],
                                         self.cssDeclTokens(['v3d-simple-preloader-logo']))

                if preTokens:
                    imgTokens = filterTokens(preTokens, [('IDENT', 'background-image'), 'COLON', 'URI', 'SEMI'])
                    if imgTokens:
                        return nthOfType(imgTokens, 'URI', 0)

            elif setting == 'preloaderImageWidth':
                for preTokens in self.filterCSSRules(tokens, 'v3d-simple-preloader-container'):
                    lenTokens = filterTokens(preTokens, ['VARPREFIX', ('IDENT', 'v3d-preloader-img-width'), 'COLON', 'LENGTH', 'SEMI'])
                    if lenTokens:
                        return nthOfType(lenTokens, 'LENGTH', 0)

            elif setting == 'preloaderImageHeight':
                for preTokens in self.filterCSSRules(tokens, 'v3d-simple-preloader-container'):
                    lenTokens = filterTokens(preTokens, ['VARPREFIX', ('IDENT', 'v3d-preloader-img-height'), 'COLON', 'LENGTH', 'SEMI'])
                    if lenTokens:
                        return nthOfType(lenTokens, 'LENGTH', 0)

            elif setting == 'preloaderDropShadowColor':
                for bgTokens in self.filterCSSRules(tokens, 'v3d-simple-preloader-container'):
                    colTokens = filterTokens(bgTokens, [('IDENT', 'filter'), 'COLON', ('FUNCTION', 'drop-shadow('), 'LENGTH', 'LENGTH', 'LENGTH', 'HASH', 'RPAREN'])
                    if colTokens:
                        return nthOfType(colTokens, 'HASH', 0)

            elif setting == 'preloaderDropShadowWidth':
                for bgTokens in self.filterCSSRules(tokens, 'v3d-simple-preloader-container'):
                    colTokens = filterTokens(bgTokens, [('IDENT', 'filter'), 'COLON', ('FUNCTION', 'drop-shadow('), 'LENGTH', 'LENGTH', 'LENGTH', 'HASH', 'RPAREN'])
                    if colTokens:
                        return nthOfType(colTokens, 'LENGTH', 2)

            elif setting == 'preloaderBarSolidColor':
                preTokens = filterTokens(tokens, [('IDENT', 'v3d-simple-preloader-bar'), 'LBRACE', 'RBRACE'],
                                         self.cssDeclTokens(['v3d-simple-preloader-bar']))

                if preTokens:
                    bgTokens = filterTokens(preTokens, [('IDENT', 'background'), 'COLON', 'HASH', 'SEMI'])
                    if bgTokens:
                        return nthOfType(bgTokens, 'HASH', 0)

            elif setting in ['preloaderBarGradientColor', 'preloaderBarGradientColor2']:
                preTokens = filterTokens(tokens, [('IDENT', 'v3d-simple-preloader-bar'), 'LBRACE', 'RBRACE'],
                                         self.cssDeclTokens(['v3d-simple-preloader-bar']))

                if preTokens:
                    bgTokens = filterTokens(preTokens, [('IDENT', 'background'), 'COLON', ('FUNCTION', 'linear-gradient('),
                                                     ('ANGLE', '90deg'), 'COMMA', 'HASH', 'COMMA', 'HASH', 'RPAREN', 'SEMI'])

                    if bgTokens:
                        if setting == 'preloaderBarGradientColor':
                            return nthOfType(bgTokens, 'HASH', 0)
                        else:
                            return nthOfType(bgTokens, 'HASH', 1)

            elif setting == 'preloaderBarCSSColor':
                preTokens = filterTokens(tokens, [('IDENT', 'v3d-simple-preloader-bar'), 'LBRACE', 'RBRACE'],
                                         self.cssDeclTokens(['v3d-simple-preloader-bar']))

                if preTokens:
                    bgTokens = filterTokens(preTokens, [('IDENT', 'background'), 'COLON', 'SEMI'],
                                            self.cssValueTokens())
                    if bgTokens:
                        valTokens = appparse.stripTokens(bgTokens, [('IDENT', 'background'), 'COLON', 'SEMI', 'SPACE'])
                        return valTokens

            elif setting == 'preloaderBarHeight':
                preTokens = filterTokens(tokens, [('IDENT', 'v3d-simple-preloader-bar'), 'LBRACE', 'RBRACE'],
                                         self.cssDeclTokens(['v3d-simple-preloader-bar']))

                if preTokens:
                    heiTokens = filterTokens(preTokens, [('IDENT', 'height'), 'COLON', 'LENGTH', 'SEMI'])
                    if heiTokens:
                        return nthOfType(heiTokens, 'LENGTH', 0)

            elif setting == 'preloaderBarBorderColor':
                preTokens = filterTokens(tokens, [('IDENT', 'v3d-simple-preloader-bar'), 'LBRACE', 'RBRACE'],
                                         self.cssDeclTokens(['v3d-simple-preloader-bar']))

                if preTokens:
                    borTokens = filterTokens(preTokens, [('IDENT', 'border-color'), 'COLON', 'HASH', 'SEMI'])
                    if borTokens:
                        return nthOfType(borTokens, 'HASH', 0)

            elif setting == 'preloaderBackColor':
                for bgTokens in self.filterCSSRules(tokens, 'v3d-simple-preloader-background'):
                    colTokens = filterTokens(bgTokens, [('IDENT', 'background-color'), 'COLON', 'HASH', 'SEMI'])
                    if colTokens:
                        return nthOfType(colTokens, 'HASH', 0)

            elif setting == 'fullscreenOpenImage':
                fsTokens = filterTokens(tokens, [('IDENT', 'fullscreen-open'), 'LBRACE', 'RBRACE'],
                                         self.cssDeclTokens(['fullscreen-open']))

                if fsTokens:
                    imgTokens = filterTokens(fsTokens, [('IDENT', 'background-image'), 'COLON', 'URI', 'SEMI'])
                    if imgTokens:
                        return nthOfType(imgTokens, 'URI', 0)

            elif setting == 'fullscreenCloseImage':
                fsTokens = filterTokens(tokens, [('IDENT', 'fullscreen-close'), 'LBRACE', 'RBRACE'],
                                         self.cssDeclTokens(['fullscreen-close']))

                if fsTokens:
                    imgTokens = filterTokens(fsTokens, [('IDENT', 'background-image'), 'COLON', 'URI', 'SEMI'])
                    if imgTokens:
                        return nthOfType(imgTokens, 'URI', 0)

            elif setting == 'moveForwardImage':
                mfTokens = filterTokens(tokens, [('IDENT', 'v3d-mobile-forward'), 'LBRACE', 'RBRACE'],
                                         self.cssDeclTokens(['v3d-mobile-forward']))

                if mfTokens:
                    imgTokens = filterTokens(mfTokens, [('IDENT', 'background-image'), 'COLON', 'URI', 'SEMI'])
                    if imgTokens:
                        return nthOfType(imgTokens, 'URI', 0)

            elif setting == 'webglErrorImage':
                errTokens = filterTokens(tokens, [('IDENT', 'v3d-webgl-error-image'), 'LBRACE', 'RBRACE'],
                                         self.cssDeclTokens(['v3d-webgl-error-image']))

                if errTokens:
                    imgTokens = filterTokens(errTokens, [('IDENT', 'background-image'), 'COLON', 'URI', 'SEMI'])
                    if imgTokens:
                        return nthOfType(imgTokens, 'URI', 0)

            elif setting == 'webglErrorMsg':
                errTokens = filterTokens(tokens, [('IDENT', 'webglErrorMsg'), 'EQUAL', 'STRING_LITERAL', 'SEMI'])
                if errTokens:
                    return nthOfType(errTokens, 'STRING_LITERAL', 0)

            elif setting == 'offlinePWA':
                swTokens = filterTokens(tokens, ['IF', 'LPAREN', 'BOOLEAN_LITERAL', 'RPAREN',
                                                 ('IDENT', 'navigator'), 'DOT',
                                                 ('IDENT', 'serviceWorker'), 'DOT',
                                                 ('IDENT', 'register'), 'LPAREN', 'STRING_LITERAL', 'RPAREN', 'SEMI'])
                if swTokens:
                    return nthOfType(swTokens, 'BOOLEAN_LITERAL', 0)

            elif setting == 'favicon192':
                icoTokens = filterTokens(tokens, [('STRING_LITERAL', 'src'), 'COLON', 'STRING_LITERAL', 'COMMA',
                                                  ('STRING_LITERAL', 'sizes'), 'COLON', ('STRING_LITERAL', '192x192')])
                if icoTokens:
                    return nthOfType(icoTokens, 'STRING_LITERAL', 1)

            elif setting == 'favicon512':
                icoTokens = filterTokens(tokens, [('STRING_LITERAL', 'src'), 'COLON', 'STRING_LITERAL', 'COMMA',
                                                  ('STRING_LITERAL', 'sizes'), 'COLON', ('STRING_LITERAL', '512x512')])
                if icoTokens:
                    return nthOfType(icoTokens, 'STRING_LITERAL', 1)

            elif setting == 'webAppName':
                nameTokens = filterTokens(tokens, [('STRING_LITERAL', 'name'), 'COLON', 'STRING_LITERAL', 'COMMA'])
                if nameTokens:
                    return nthOfType(nameTokens, 'STRING_LITERAL', 1)

            elif setting == 'startURL':
                nameTokens = filterTokens(tokens, [('STRING_LITERAL', 'start_url'), 'COLON', 'STRING_LITERAL', 'COMMA'])
                if nameTokens:
                    return nthOfType(nameTokens, 'STRING_LITERAL', 1)

            return None

    class RootHandler(tornado.web.RequestHandler, ResponseTemplates):
        async def get(self):
            server = self.settings['server']
            await server.runAsync(self.rootRequestThread)

        def rootRequestThread(self):
            server = self.settings['server']
            server.loadSettings()

            try:
                apps = server.findApps()
            except FileNotFoundError:
                self.writeError('No applications directory found!', False)
                return
            except PermissionError:
                self.writeError('Access is denied. Make sure that you have permissions to read ' +
                                'from Verge3D installation directory.', False)
                return

            appsViewInfo = []

            for app in apps:
                appsViewInfo.append(server.genAppViewInfo(app))

            self.write(self.renderTemplate('main.tpl', {
                'apps': appsViewInfo,
                'appTemplates': server.amSettings['appTemplates'],
                'theme': server.amSettings['theme'],
                'manualURL': server.amSettings['manualURL'],
                'simplifiedMobileUI': server.amSettings['simplifiedMobileUI'],
                'licenseInfo': server.getLicenseInfo(),
                'productName': server.getProductName(),
                'package': self.getReqModPack(),
                'jsContext': self.getJSContext()
            }))

    class OpenFileHandler(tornado.web.RequestHandler, ResponseTemplates):
        def get(self):
            try:
                filepath = self.get_argument('filepath')
            except tornado.web.MissingArgumentError:
                self.write('Specify file name')

            server = self.settings['server']

            path = str(server.resolveURLPath(filepath))
            log.info('Opening {}'.format(path))

            if sys.platform.startswith('darwin'):
                if os.path.isdir(path) or os.path.splitext(path)[1].lower() in ['.ma', '.mb']:
                    subprocess.call(('open', path))
                else:
                    subprocess.call(('open', '-n', path))
            elif os.name == 'nt':
                try:
                    os.startfile(path)
                except:
                    pass
            elif os.name == 'posix':
                subprocess.Popen(('xdg-open', path))

            self.write('ok')

    class DeleteConfirmHandler(tornado.web.RequestHandler, ResponseTemplates):
        def get(self):
            try:
                app = self.get_argument('app')
            except tornado.web.MissingArgumentError:
                self.writeError('Please specify app to delete')
                return

            server = self.settings['server']

            appInfo = server.findApp(app)

            if not appInfo:
                self.writeError('Could not find app: {0}.'.format(app))
                return

            self.write(self.renderTemplate('dialog_delete_confirm.tpl', {
                'app': app,
                'manageURL': server.manageURL(app),
                'title': appInfo['title']
            }))

    class DeleteFileHandler(tornado.web.RequestHandler, ResponseTemplates):
        def get(self):
            try:
                app = self.get_argument('app')
            except tornado.web.MissingArgumentError:
                self.writeError('Please specify app to delete')
                return

            server = self.settings['server']

            appInfo = server.findApp(app)
            if not appInfo:
                self.writeError('Could not find app: {0}.'.format(app))
                return

            appDir = appInfo['appDir']

            try:
                send2trash.send2trash(str(appDir))
            except:
                self.writeError('Could not delete app folder! Maybe some file is still in use?')
                return

            self.write(self.renderTemplate('dialog_delete_done.tpl', {
                'title': appInfo['title']
            }))

    class CreateAppHandler(tornado.web.RequestHandler, ResponseTemplates):

        def post(self):
            try:
                name = self.get_argument('app_name')
                nameDisp = self.get_argument('app_name_disp')
            except tornado.web.MissingArgumentError:
                self.writeError('Please specify app name')
                return

            if re.search('[/\\\\]', name):
                self.writeError('App name contains invalid characters. Please try another name.')
                return

            try:
                tplName = self.get_argument('template_name')
            except tornado.web.MissingArgumentError:
                self.writeError('Please specify app template name')
                return

            server = self.settings['server']

            if not server.verifyTemplate(tplName):
                self.writeError('Template not found: ' + tplName)
                return

            try:
                copyOpentypeModule = server.argBool(self.get_argument('copy_opentype_module'))
            except tornado.web.MissingArgumentError:
                copyOpentypeModule = False

            try:
                copyPhysicsModule = server.argBool(self.get_argument('copy_physics_module'))
            except tornado.web.MissingArgumentError:
                copyPhysicsModule = False

            try:
                copyKTX2Module = server.argBool(self.get_argument('copy_ktx2_module'))
            except tornado.web.MissingArgumentError:
                copyKTX2Module = False

            if name == '':
                self.writeError('Please specify a name for your application.')
                return

            appDir = server.getExtAppsDir() / name

            if DEBUG:
                name = re.sub(TEST_APP_PREFIX, '', name)

            log.info('Creating app {} in folder {}'.format(name, appDir))

            try:
                os.mkdir(appDir)
            except FileExistsError:
                status = 'Application <a href="/manage/?app=' + name + '" class="colored-link">' + nameDisp
                status += '</a> already exists. Either remove it first, or try another name.'
                self.writeError(status)
                return
            except PermissionError:
                self.writeError(ACCESS_DENIED_MSG)
                return

            modules = ['v3d.js']

            if copyOpentypeModule:
                modules.append('opentype.js')

            if copyPhysicsModule:
                modules.append('ammo.wasm.js')
                modules.append('ammo.wasm.wasm')

            if copyKTX2Module:
                modules.append('basis_transcoder.js')
                modules.append('basis_transcoder.wasm')

            server.copyAppTemplate(name, appDir, tplName, self.getReqModPack(), modules)

            self.write(self.renderTemplate('dialog_new_app_created.tpl', {
                'nameDisp': nameDisp,
                'manageURL': server.manageURL(appDir.name)
            }))

    class ManageAppHandler(tornado.web.RequestHandler, ResponseTemplates):

        def get(self):
            try:
                app = self.get_argument('app')
            except tornado.web.MissingArgumentError:
                self.writeError('Please specify app', False)
                return

            server = self.settings['server']
            server.loadSettings()

            appInfo = server.findApp(app)
            if not appInfo:
                self.writeError('Could not find app: {}.'.format(app), False)
                return

            appViewInfo = server.genAppViewInfo(appInfo)

            self.write(self.renderTemplate('manage.tpl', {
                'app': appViewInfo,
                'appTemplates': server.amSettings['appTemplates'],
                'theme': server.amSettings['theme'],
                'manualURL': server.amSettings['manualURL'],
                'simplifiedMobileUI': server.amSettings['simplifiedMobileUI'],
                'licenseInfo': server.getLicenseInfo(),
                'productName': server.getProductName(),
                'package': self.getReqModPack(),
                'jsContext': self.getJSContext()
            }))

    class AppSettingsHandler(tornado.web.RequestHandler, ResponseTemplates, AppTokens):
        def urlIsAbsolute(self, url):
            return bool(urllib.parse.urlparse(url).netloc)

        def appPathRelToURL(self, appPath, path):
            if self.urlIsAbsolute(path):
                return path
            else:
                server = self.settings['server']
                return APPS_PREFIX + server.pathToUrl(appPath + '/' + path, quote=False)

        def rgbToHex(self, r, g, b):
            return '#{:02x}{:02x}{:02x}'.format(int(r), int(g), int(b))

        def expandColorTriplet(self, color):
            return '#' + color[1] + color[1] + color[2] + color[2] + color[3] + color[3]

        def get(self):
            try:
                app = self.get_argument('app')
            except tornado.web.MissingArgumentError:
                self.writeError('Please specify App name')
                return

            server = self.settings['server']
            server.loadSettings()

            appInfo = server.findApp(app)
            if not appInfo:
                self.writeError('Could not find app: {}.'.format(app))
                return

            sortedHTMLs = server.sortHTMLs(appInfo['name'], appInfo['html'])
            if len(sortedHTMLs) == 0:
                self.writeError('No HTML in app: {}.'.format(app))
                return

            css = server.findMainCSS(app, appInfo['appDir'])
            if not css:
                self.writeError('No suitable CSS file found.')
                return

            js = server.findMainJS(app, appInfo['appDir'])
            if not js:
                self.writeError('No suitable JavaScript file found.')
                return

            manifest = server.findManifest(appInfo['appDir'])
            if not manifest:
                log.warning('No suitable manifest file found.')

            settings = {}

            htmlTokens = appparse.getHTMLTokens(appInfo['appsDir'] / sortedHTMLs[0])
            cssTokens = appparse.getCSSTokens(css)
            jsTokens = appparse.getJSTokens(js)
            manifestTokens = appparse.getJSTokens(manifest) if manifest else []

            for setting in self.htmlSettingNames():
                token = self.getAppSettingToken(setting, htmlTokens)
                if token:
                    value = appparse.tokValueGet(token)
                    if 'favicon' in setting or setting == 'openGraphImage':
                        value = self.appPathRelToURL(appInfo['path'], value)
                    settings[setting] = value

            for setting in self.cssSettingNames():
                token = self.getAppSettingToken(setting, cssTokens)

                if not token:
                    token = self.getAppSettingToken(setting, self.genFallbackTokens(setting, cssTokens))

                if token:
                    if type(token) == tuple: # RGBA
                        value = self.rgbToHex(token[0].value, token[1].value, token[2].value)

                    elif type(token) == list: # e.g. preloaderBarCSSColor
                        value = ''.join([tok.value for tok in token])

                    elif token.type == 'LENGTH':
                        value = appparse.tokValueGet(token).replace('px', '')

                    elif token.type == 'URI':
                        path = appparse.tokValueGet(token)
                        if 'media/' in path:
                            value = self.appPathRelToURL(appInfo['path'], path)
                        else:
                            value = path

                    elif token.type == 'HASH' and len(token.value) == 4: # e.g #fff -> #ffffff
                        value = self.expandColorTriplet(appparse.tokValueGet(token))

                    else:
                        value = appparse.tokValueGet(token)

                    settings[setting] = value

            for setting in self.jsSettingNames():
                token = self.getAppSettingToken(setting, jsTokens)

                if not token:
                    token = self.getAppSettingToken(setting, self.genFallbackTokens(setting, jsTokens))

                if token:
                    value = appparse.tokValueGet(token)
                    if setting == 'webglErrorMsg':
                        settings[setting] = markupsafe.escape(value)
                    else:
                        settings[setting] = value

            for setting in self.manifestSettingNames():
                token = self.getAppSettingToken(setting, manifestTokens)
                if token:
                    value = appparse.tokValueGet(token)
                    if setting == 'webAppName' or setting == 'startURL':
                        settings[setting] = value
                    else:
                        settings[setting] = self.appPathRelToURL(appInfo['path'], 'media/' + value)

            self.write(self.renderTemplate('dialog_app_settings.tpl', {
                'settings': settings,
                'app': appInfo,
                'manualURL': server.amSettings['manualURL'],
            }))

    class AppSettingsSaveHandler(tornado.web.RequestHandler, ResponseTemplates, AppTokens):

        def urlToAppPathRel(self, url):
            return 'media/' + url.split('/media/')[1]

        def hexToRGB(self, hexStr):
            rgb = []
            hexStr = hexStr.lstrip('#')
            for i in (0, 2, 4):
                decimal = int(hexStr[i:i+2], 16)
                rgb.append(decimal)
            return tuple(rgb)

        def post(self, tail=None):

            settings = json.loads(self.request.body)
            app = settings.pop('app')

            server = self.settings['server']

            appInfo = server.findApp(app)
            if not appInfo:
                self.writeError('Could not find app: {}.'.format(app), False)
                return

            if len(settings.keys()):
                log.info('Processing app settings: {}'.format(' '.join(settings.keys())))
            else:
                return

            sortedHTMLs = server.sortHTMLs(appInfo['name'], appInfo['html'])
            if len(sortedHTMLs) == 0:
                self.writeError('No HTML in app: {}.'.format(app), False)
                return

            html = appInfo['appsDir'] / sortedHTMLs[0]

            css = server.findMainCSS(app, appInfo['appDir'])
            if not css:
                self.writeError('No suitable CSS file found.')
                return

            js = server.findMainJS(app, appInfo['appDir'])
            if not js:
                self.writeError('No suitable JavaScript file found.')
                return

            manifest = server.findManifest(appInfo['appDir'])

            htmlTokens = appparse.getHTMLTokens(html)
            cssTokens = appparse.getCSSTokens(css)
            jsTokens = appparse.getJSTokens(js)
            manifestTokens = appparse.getJSTokens(manifest) if manifest else []

            writeHTML = False
            writeCSS = False
            writeJS = False
            writeManifest = False

            for setting in self.htmlSettingNames():
                if setting in settings:
                    token = self.getAppSettingToken(setting, htmlTokens)
                    if token:
                        value = settings[setting]

                        if 'favicon' in setting or setting == 'openGraphImage':
                            value = self.urlToAppPathRel(value)
                        else: # title, description etc
                            value = htmlEscape(value)

                        appparse.tokValueSet(token, value)

                        writeHTML = True

            for setting in self.cssSettingNames():
                if setting in settings:
                    token = self.getAppSettingToken(setting, cssTokens)

                    if not token:
                        token = self.getAppSettingToken(setting, self.genFallbackTokens(setting, cssTokens))

                    if token:
                        value = settings[setting]

                        if type(token) == tuple: # RGBA
                            rgb = self.hexToRGB(value)
                            appparse.tokValueSet(token[0], str(rgb[0]))
                            appparse.tokValueSet(token[1], str(rgb[1]))
                            appparse.tokValueSet(token[2], str(rgb[2]))

                        elif type(token) == list: # e.g. preloaderBarCSSColor
                            appparse.tokValueSet(token[0], value)
                            for i in range(1, len(token)):
                                token[i].value = ''

                        elif token.type == 'LENGTH':
                            appparse.tokValueSet(token, str(value) + 'px')

                        elif token.type == 'URI':
                            if '/media/' not in value:
                                log.warning('Invalid asset URL: {}'.format(value))
                                continue
                            url = self.urlToAppPathRel(value)
                            appparse.tokValueSet(token, url)

                        else:
                            appparse.tokValueSet(token, value)

                        writeCSS = True

            for setting in self.jsSettingNames():
                if setting in settings:
                    token = self.getAppSettingToken(setting, jsTokens)

                    if not token:
                        token = self.getAppSettingToken(setting, self.genFallbackTokens(setting, jsTokens))

                    if token:
                        value = settings[setting]
                        appparse.tokValueSet(token, value)

                        if setting == 'offlinePWA':
                            server.genServiceWorker(appInfo['appDir'], 'ENABLE' if value == 'true' else 'DISABLE')

                        writeJS = True

            for setting in self.manifestSettingNames():
                if setting in settings:
                    token = self.getAppSettingToken(setting, manifestTokens)
                    if token:
                        if setting == 'webAppName' or setting == 'startURL':
                            appparse.tokValueSet(token, settings[setting])
                        else:
                            appparse.tokValueSet(token, settings[setting].split('/media/')[1])

                        writeManifest = True

            if writeHTML:
                log.info('Saving {}'.format(os.path.basename(html)))
                appparse.writeTokens(htmlTokens, html)

            if writeCSS:
                log.info('Saving {}'.format(os.path.basename(css)))
                appparse.writeTokens(cssTokens, css)

            if writeJS:
                log.info('Saving {}'.format(os.path.basename(js)))
                appparse.writeTokens(jsTokens, js)

            if writeManifest:
                log.info('Saving {}'.format(os.path.basename(manifest)))
                appparse.writeTokens(manifestTokens, manifest)

    class AppSettingsSaveImageHandler(tornado.web.RequestHandler, ResponseTemplates):

        def post(self, tail=None):

            try:
                app = self.get_argument('app')
            except tornado.web.MissingArgumentError:
                self.write('')
                return

            server = self.settings['server']

            appInfo = server.findApp(app)
            if not appInfo:
                self.write('')
                return

            image = self.request.files['image'][0]

            imageName = image['filename']
            imageData = image['body']
            imageType = image['content_type']

            if imageType not in ['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml']:
                self.write('')
                return

            appDir = appInfo['appDir']
            mediaDir = appDir / 'media'
            mediaDir.mkdir(exist_ok=True)

            log.info('Saving {}'.format(imageName))

            with open(mediaDir / imageName, 'wb') as f:
                f.write(imageData)

            self.write(APPS_PREFIX + server.pathToUrl(appInfo['path'] + '/media/' + imageName))

    class AppSettingsUpdateWorkerHandler(tornado.web.RequestHandler, ResponseTemplates):

        def get(self):
            try:
                app = self.get_argument('app')
            except tornado.web.MissingArgumentError:
                self.write('')
                return

            server = self.settings['server']

            appInfo = server.findApp(app)
            if not appInfo:
                self.write('')
                return

            server.genServiceWorker(appInfo['appDir'], 'UPDATE')

            self.write('ok')

    class ProcessNetworkHandler(tornado.web.RequestHandler, ResponseTemplates):

        def writeS3Error(self, e, dialog=True):

            type = 'NET_ERR_UNKNOWN'
            error = ''

            if isinstance(e, boto3.exceptions.S3UploadFailedError):
                error = str(e)

                if 'RequestTimeTooSkewed' in error:
                    type = 'NET_ERR_TIME_SKEWED'
                    self.set_status(400)
                else:
                    self.set_status(500)

            elif isinstance(e, TransferCancelledException):
                type = 'NET_ERR_CANCELLED'
                self.set_status(200)

            elif isinstance(e, NothingToTransferException):
                type = 'NET_ERR_NOTHING'
                self.set_status(400)

            else:
                error = e.response['Error']['Code']

                if error == 'RequestTimeTooSkewed':
                    type = 'NET_ERR_TIME_SKEWED'
                    self.set_status(400)
                else:
                    self.set_status(500)

            if type != 'NET_ERR_UNKNOWN':
                message = type
            else:
                message = error

            if dialog:
                self.write(self.renderTemplate('dialog_error.tpl', {
                    'message': message
                }))
            else:
                self.write(self.renderTemplate('error.tpl', {
                    'message': message,
                    'jsContext': self.getJSContext()
                }))

        def calcMD5(self, file):
            h = hashlib.md5()

            with open(file, 'rb') as fp:
                content = fp.read()
                h.update(content)

            return h.hexdigest()

        def uploadS3File(self, s3, credentials, relativePath, relativeTo):
            """relative path, returns uploaded URL"""

            server = self.settings['server']

            if server.transferCancelled:
                raise TransferCancelledException

            bucket = credentials['aws_bucket']
            userDir = credentials['aws_user_dir']

            localPath = os.path.join(relativeTo, relativePath)

            key = userDir + '/' + server.pathToUrl(relativePath, quote=False)

            cacheControl = 'max-age=' + str(server.amSettings['cacheMaxAge'] * 60)

            try:
                response = s3.head_object(Bucket=bucket, Key=key)
                etag = response['ETag'].strip('"')
                cacheControlRemote = response['CacheControl']

                if self.calcMD5(localPath) == etag and cacheControl == cacheControlRemote:
                    log.info('Skipping already uploaded: {}'.format(bucket + '/' + key))
                    return CDN_URL + key

            except s3.exceptions.ClientError:
                pass

            log.info('Uploading: {}'.format(bucket + '/' + key))

            if os.path.splitext(relativePath)[1] == '.gltf':
                mime_type = 'application/json'
            else:
                mime_type = mimetypes.guess_type(key)[0]

            if not mime_type:
                mime_type = 'application/octet-stream'

            extra_args = {
                'ContentType': mime_type,
                'ACL': 'public-read',
                'CacheControl': cacheControl
            }
            s3.upload_file(localPath, bucket, key, ExtraArgs=extra_args)

            return CDN_URL + key

        async def get(self, req='status'):
            server = self.settings['server']
            await server.runAsync(self.netRequestThread, req)

        def netRequestThread(self, req):

            server = self.settings['server']

            if req not in ALLOWED_NET_REQUESTS:
                self.set_status(400)
                self.writeError('Bad Verge3D Network request', False)
                return

            dialogMode = (req != 'status')

            if req == 'progress':
                self.write(str(server.transferProgress))
                return

            elif req == 'cancel':
                server.transferProgress = 100
                server.transferCancelled = True
                return

            server.transferProgress = 0
            server.transferCancelled = False

            log.info('Requesting S3 credentials')

            cred = server.getS3Credentials()
            if not cred:
                self.set_status(502)
                self.writeError('Failed to receive Verge3D Network credentials, possibly due to connection error.',
                                dialogMode)
                return

            if req == 'upload':
                licenseInfo = server.getLicenseInfo()
                if not licenseInfo['maintenance']:
                    if licenseInfo['type'] != 'TRIAL':
                        self.writeError('<div class="dialog-text-center red">Your Verge3D maintenance subscription expired on {}.</div><div>Please consider <a href="https://www.soft8soft.com/verge3d-subscription-renewal/" target="_blank" class="colored-link">renewing</a> your subscription to continue using Verge3D Network!</div>'.format(licenseInfo['validUntil'].date().strftime('%d %B %Y')))
                        return
                    elif datetime.datetime.now(tz=datetime.timezone.utc) > licenseInfo['validUntil']:
                        self.writeError('<div class="dialog-text-center red">Your Verge3D Trial period has ended on {}.</div><div>Please make sure to <a href="https://www.soft8soft.com/licensing/" target="_blank" class="colored-link">acquire</a> a license to continue using Verge3D Network!</div>'.format(licenseInfo['validUntil'].date().strftime('%d %B %Y')))
                        return

            config = botocore.client.Config(
                signature_version='s3v4'
            )

            proxyHost, proxyPort, proxyUser, proxyPass = server.getProxySettings()
            if proxyHost:
                if proxyUser:
                    proxyLine = 'http://{}:{}@{}:{}'.format(proxyUser, proxyPass, proxyHost, proxyPort)
                else:
                    proxyLine = 'http://{}:{}'.format(proxyHost, proxyPort)

                config.proxies = { 'https': proxyLine }

            s3 = boto3.client('s3',
                aws_access_key_id = cred['aws_access_key_id'],
                aws_secret_access_key = cred['aws_secret_access_key'],
                aws_session_token = cred['aws_session_token'],
                config=config,
                region_name = 'eu-central-1'
            )

            server.transferProgress = 10

            if req == 'status':

                self.processStatusRequest(s3, cred)

            elif req == 'delete':
                bucket = cred['aws_bucket']

                keys = self.get_arguments('key')

                if not len(keys) or keys[0] == '':
                    self.writeError('No files selected for deletion.')
                    self.set_status(400)
                    return

                delete_keys = {'Objects' : []}

                for key in keys:
                    log.info('Removing key: ' + key)
                    delete_keys['Objects'].append({'Key' : key})

                try:
                    resp = s3.delete_objects(Bucket=bucket, Delete=delete_keys)
                except botocore.exceptions.ClientError as e:
                    self.writeS3Error(e)
                    return

                self.write(self.renderTemplate('dialog_network_delete_done.tpl', {
                    'numFiles': str(len(keys))
                }))

                server.transferProgress = 100

            elif req == 'upload':

                self.processUploadRequest(s3, cred)

            elif req == 'download':

                self.processDownloadRequest(s3, cred)

        def processStatusRequest(self, s3, cred):

            server = self.settings['server']

            bucket = cred['aws_bucket']
            userDir = cred['aws_user_dir']

            prefix = userDir + '/'

            paginator = s3.get_paginator('list_objects_v2')
            pages = paginator.paginate(Bucket=bucket, Prefix=prefix, PaginationConfig={'PageSize': 1000})

            keys = []
            keysSplit = []

            sizeInfo = {}
            dateInfo = {}

            filesViewInfo = []

            maxDirLevels = 0

            try:
                for page in pages:
                    if 'Contents' in page:
                        for obj in page['Contents']:
                            key = obj['Key']
                            keys.append(key)
                            keysSplit.append(key.split('/'))
                            maxDirLevels = max(maxDirLevels, len(keysSplit[-1]))

                            sizeInfo[key] = obj['Size']
                            dateInfo[key] = obj['LastModified']

                processedDirs = set()

                for level in range(maxDirLevels):
                    for key, keySplit in zip(keys, keysSplit):
                        if level < len(keySplit) - 1:
                            dir = '/'.join(keySplit[0:level+1])
                            dir += '/'

                            if not (dir in processedDirs):
                                processedDirs.add(dir)
                                sizeInfo[dir] = 0
                                dateInfo[dir] = dateInfo[key]

                            sizeInfo[dir] += sizeInfo[key]
                            dateInfo[dir] = max(dateInfo[dir], dateInfo[key])

                keys += list(processedDirs)
                keys.sort()

                keys = keys[0:50000]

                for key in keys:

                    path = key.replace(prefix, '/').rstrip('/')

                    isDir = (key[-1] == '/')
                    indent = len(path.split('/')) - 1
                    url = '' if isDir else (CDN_URL + server.pathToUrl(key, replaceBackslash=False))

                    fileViewInfo = {
                        'name': os.path.basename(path),
                        'isDir': isDir,
                        'indent': indent,
                        'key': server.pathToUrl(key, replaceBackslash=False),
                        'url': url,
                        'size': sizeInfo[key],
                        'date': dateInfo[key].astimezone().strftime('%Y-%m-%d %H:%M')
                    }

                    filesViewInfo.append(fileViewInfo)
            except botocore.exceptions.ClientError as e:
                self.writeS3Error(e, dialogMode)
                return
            except AttributeError:
                self.writeError('NET_ERR_TIME_SKEWED', dialogMode)
                return

            self.write(self.renderTemplate('network.tpl', {
                'filesViewInfo': filesViewInfo,
                'maxDirSize': cred['aws_user_max_dir_size'],
                'appTemplates': server.amSettings['appTemplates'],
                'theme': server.amSettings['theme'],
                'manualURL': server.amSettings['manualURL'],
                'simplifiedMobileUI': server.amSettings['simplifiedMobileUI'],
                'licenseInfo': server.getLicenseInfo(),
                'productName': server.getProductName(),
                'package': self.getReqModPack(),
                'jsContext': self.getJSContext()
            }))

            server.transferProgress = 100

        def processUploadRequest(self, s3, cred):

            server = self.settings['server']

            appInfo = server.findApp(self.get_argument('app'))
            if not appInfo:
                self.writeError('Could not find app folder.')
                return

            appDir = appInfo['appDir']
            appsDir = appInfo['appsDir']

            isZip = bool(int(self.get_argument('zip')))

            uploadList = []

            for p in list(appDir.rglob('*')):
                if p.is_dir():
                    continue

                if p.lstat().st_size == 0:
                    continue

                ignore = False

                if ((not isZip and server.amSettings['uploadSources'] == False) or
                        (isZip and server.amSettings['exportSources'] == False)):
                    for pattern in APP_SRC_IGNORE:
                        if p.match(pattern):
                            ignore = True
                            break

                if ignore:
                    continue

                uploadList.append(p)

            if not len(uploadList):
                self.writeS3Error(NothingToTransferException())
                return

            htmlLinks = []

            if isZip:
                distDir = pathlib.Path(server.serverTempDir) / DIST_TMP_DIR
                zipPath = distDir / (appDir.name + '.zip')

                with zipfile.ZipFile(zipPath, 'w', compression=zipfile.ZIP_DEFLATED) as appzip:
                    for p in uploadList:
                        appzip.write(p, p.relative_to(appInfo['appsDir']))

                try:
                    url = self.uploadS3File(s3, cred, zipPath.relative_to(distDir), distDir)
                except boto3.exceptions.S3UploadFailedError as e:
                    self.writeS3Error(e)
                    return
                except AttributeError:
                    self.writeError('NET_ERR_TIME_SKEWED')
                    return
                except TransferCancelledException as e:
                    self.writeS3Error(e)
                    return

                htmlLinks.append(url)

            else:
                progressInc = 90 / len(uploadList)

                for p in uploadList:

                    try:
                        url = self.uploadS3File(s3, cred, p.relative_to(appsDir), appsDir)
                    except boto3.exceptions.S3UploadFailedError as e:
                        self.writeS3Error(e)
                        return
                    except AttributeError:
                        self.writeError('NET_ERR_TIME_SKEWED')
                        return
                    except TransferCancelledException as e:
                        self.writeS3Error(e)
                        return

                    if p.suffix == '.html':
                        htmlLinks.append(url)

                    server.transferProgress += progressInc

            server.transferProgress = 100

            uploadsViewInfo = []
            shortenerLinks = []

            for link in sorted(htmlLinks):
                uploadsViewInfo.append({
                    'title': appInfo['title'],
                    'url' : link,
                    'shorturl' : server.urlBasename(link),
                    'socialText' : 'Check out this interactive web application made with Verge3D!'
                })
                shortenerLinks.append(link)

            if server.amSettings['useLinkShortener']:
                shortenedLinks = server.shortenLinks(shortenerLinks)

                for info in uploadsViewInfo:
                    for slinkInfo in shortenedLinks:
                        if info['url'] == slinkInfo['long_url']:
                            info['url'] = slinkInfo['short_url']

            self.write(self.renderTemplate('dialog_published.tpl', {
                'uploadsViewInfo': uploadsViewInfo,
                'isZip': isZip,
                'licenseInfo': server.getLicenseInfo()
            }))

        def processDownloadRequest(self, s3, credentials):

            server = self.settings['server']

            keys = self.get_arguments('key')

            if not len(keys) or keys[0] == '':
                self.writeS3Error(NothingToTransferException())
                return

            for key in keys:
                try:
                    if server.transferCancelled:
                        raise TransferCancelledException

                    bucket = credentials['aws_bucket']
                    userDir = credentials['aws_user_dir']

                    log.info('Downloading: {}'.format(bucket + '/' + key))

                    keyRelToDir = key[len(userDir):]

                    dest = server.resolveURLPath('/applications' + keyRelToDir)

                    os.makedirs(dest.parent, exist_ok=True)

                    s3.download_file(Bucket=bucket, Key=key, Filename=str(dest))

                    progressInc = 90 / len(keys)
                    server.transferProgress += progressInc

                except botocore.exceptions.ClientError as e:
                    self.writeS3Error(e)
                    return

                except TransferCancelledException as e:
                    self.writeS3Error(e)
                    return

            server.transferProgress = 100

            self.write(self.renderTemplate('dialog_network_download_done.tpl', {
                'numFiles': str(len(keys))
            }))

    class SavePuzzlesHandler(tornado.web.RequestHandler):

        def initialize(self, isLibrary, libDelete):
            self.isLibrary = isLibrary
            self.libDelete = libDelete

        def post(self, tail=None):

            server = self.settings['server']

            xmlURL = self.get_argument('xmlURL', strip=False)
            xmlURL = server.resolveURLPath(xmlURL)

            xmlExists = os.path.exists(xmlURL)

            if xmlExists:

                if self.isLibrary:
                    dirname = BACKUP_LIBRARY_DIR
                else:
                    dirname = os.path.join(APP_DATA_DIR, BACKUP_PUZZLES_DIR)

                backupDir = os.path.join(os.path.dirname(xmlURL), dirname)

                server.backupFile(xmlURL, backupDir)

            xml = self.get_argument('xml', strip=False)
            log.info('Saving Puzzles XML {}'.format(xmlURL))
            if self.isLibrary:
                if xmlExists:
                    f = open(xmlURL, 'r', encoding='utf-8', newline='\n')
                    doc = minidom.parse(f)
                    f.close()
                    if self.libDelete:
                        categories = doc.getElementsByTagName('category')
                        for category in categories:
                            if category.hasAttribute('name'):
                                if category.getAttribute('name') == self.get_argument('codeURL', strip=False):
                                    doc.documentElement.removeChild(category)
                    else:
                        xml = minidom.parseString(xml)
                        doc.documentElement.appendChild(xml.documentElement)
                    f = open(xmlURL, 'w', encoding='utf-8', newline='\n')
                    doc.writexml(f)
                    f.close()
                else:
                    f = open(xmlURL, 'w', encoding='utf-8', newline='\n')
                    f.write('<xml>' + xml + '</xml>')
                    f.close()
            else:
                f = open(xmlURL, 'w', encoding='utf-8', newline='\n')
                f.write(xml)
                f.close()

            if not self.isLibrary:
                codeURL = self.get_argument('codeURL', strip=False)
                codeURL = server.resolveURLPath(codeURL)
                code = self.get_argument('code', strip=False)
                log.info('Saving Puzzles JS {}'.format(codeURL))
                f = open(codeURL, 'w', encoding='utf-8', newline='\n')
                f.write(code)
                f.close()

            if xmlExists:
                return

    class CreateAppZipHandler(tornado.web.RequestHandler, ResponseTemplates):

        async def get(self):
            try:
                app = self.get_argument('app')
            except tornado.web.MissingArgumentError:
                self.writeError('Please specify app to create zip for')
                return

            server = self.settings['server']

            appInfo = server.findApp(app)
            if not appInfo:
                self.writeError('Could not find app: {0}.'.format(app))
                return

            appDir = appInfo['appDir']

            zipPath = pathlib.Path(server.serverTempDir) / DIST_TMP_DIR / (appDir.name + '.zip')

            noTrace = lambda *p, **k: None

            with tempfile.TemporaryDirectory() as tmpDir:
                tmpDir = pathlib.Path(tmpDir)

                ignore = APP_SRC_IGNORE if (server.amSettings['exportSources'] == False) else []
                server.copyTreeMerge(appDir, tmpDir, ignore)

                log.info('Packing template ZIP: {}'.format(zipPath))

                await server.runAsync(ziptools.createzipfile, zipPath, [str(p) for p in tmpDir.glob('*')],
                                       zipat=appDir.name, trace=noTrace)

            url = server.pathToUrl(zipPath.relative_to(server.serverTempDir), makeAbs=True)

            self.write(self.renderTemplate('dialog_create_zip_done.tpl', {
                'downloadURL': url
            }))

    class DropAppZipHandler(tornado.web.RequestHandler, ResponseTemplates):

        async def post(self):

            server = self.settings['server']

            distDir = pathlib.Path(server.serverTempDir) / DIST_TMP_DIR

            file = self.request.files['file'][0]

            zipData = file['body']
            zipName = file['filename']

            with open(distDir / zipName, 'wb') as fzip:
                fzip.write(zipData)

            try:
                paths = list(zipp.Path(distDir / zipName).iterdir())
            except zipfile.BadZipFile:
                self.writeError(BAD_ZIP_MSG)
                return

            if len(paths) != 1 or not paths[0].is_dir():
                self.writeError('Application ZIP has wrong structure.')
                return

            appName = paths[0].name

            for appInfo in server.findApps():
                if appInfo['name'] == appName:
                    self.writeError('Application with such name already exists.')
                    return

            noTrace = lambda *p, **k: None

            try:
                ziptools.extractzipfile(distDir / zipName, server.getExtAppsDir(), permissions=True, trace=noTrace)
            except zipfile.BadZipFile:
                self.writeError(BAD_ZIP_MSG)
                return

            self.write(self.renderTemplate('dialog_drop_zip_done.tpl', {
                'nameDisp': server.appTitle(appName),
                'manageURL': server.manageURL(appName)
            }))

    class EnterKeyHandler(tornado.web.RequestHandler, ResponseTemplates):

        def post(self):
            try:
                key = self.get_argument('key')
            except tornado.web.MissingArgumentError:
                self.writeError('Please specify a key.')
                return

            key = key.strip()

            server = self.settings['server']

            if not keymanager.checkKey(key) or not server.checkKeyModPackage(key):
                self.writeError('<span class="red">You entered incorrect key. ' +
                        'Please try again or contact the Verge3D support service.</span>')
                return

            licenseInfo = server.getLicenseInfo(key)

            if licenseInfo['ineligibleVersion']:
                self.write(self.renderTemplate('dialog_license_key_done.tpl', {
                    'licenseInfo' : licenseInfo
                }))
                return

            try:
                server.amSettings['licenseKey'] = key
                server.amSettings['licenseKeyVersion'] = licenseInfo['releaseVersion']
                server.saveSettings()
            except PermissionError:
                self.writeError(ACCESS_DENIED_MSG)
                return

            server.activateEngine(key)

            self.write(self.renderTemplate('dialog_license_key_done.tpl', {
                'licenseInfo' : licenseInfo
            }))

    class UpdateAppInfoHandler(tornado.web.RequestHandler, ResponseTemplates):

        def get(self):
            try:
                app = self.get_argument('app')
            except tornado.web.MissingArgumentError:
                self.writeError('Please specify application')
                self.set_status(400)
                return

            server = self.settings['server']
            appInfo = server.findApp(app)

            if not appInfo or not appInfo['updateInfo']:
                self.writeError('Missing app update information.')
                return

            modules = appInfo['updateInfo']['modules']

            files = appInfo['updateInfo']['files']

            self.write(self.renderTemplate('dialog_update.tpl', {
                'app' : app,
                'modulesAll': MODULES,
                'modulesUpdated': modules,
                'files': [server.pathToUrl(f, quote=False) for f in files],
                'manualURL': server.amSettings['manualURL']
            }))

    class UpdateAppHandler(tornado.web.RequestHandler, ResponseTemplates):

        def post(self):
            try:
                app = self.get_argument('app')
            except tornado.web.MissingArgumentError:
                self.writeError('Please specify application filepath')
                self.set_status(400)
                return

            modules = self.get_arguments('module')
            files = self.get_arguments('file')

            if (not len(modules) or modules[0] == '') and (not len(files) or files[0] == ''):
                self.writeError('No files selected for updating.')
                self.set_status(400)
                return

            server = self.settings['server']
            appInfo = server.findApp(app)

            files = [pathlib.Path(f) for f in files]

            try:
                mergeConflicts = server.updateApp(appInfo, modules, files, self.getReqModPack())
            except UpdateAppException as e:
                self.set_status(400)
                self.writeError(str(e))
                return
            except PermissionError:
                self.set_status(400)
                self.writeError(ACCESS_DENIED_MSG, True)
                return

            self.write(self.renderTemplate('dialog_update_done.tpl', {
                'title': appInfo['title'],
                'mergeConflicts': mergeConflicts
            }))

    class UpdateAllAppsHandler(tornado.web.RequestHandler, ResponseTemplates):
        def get(self):

            server = self.settings['server']

            for appInfo in server.findApps():
                if appInfo['updateInfo']:
                    self.write('updating {}... '.format(appInfo['name']))

                    modules = appInfo['updateInfo']['modules']
                    files = appInfo['updateInfo']['files']

                    try:
                        mergeConflicts = server.updateApp(appInfo, modules, files, self.getReqModPack())
                    except:
                        status = 'error'
                        color = 'red'
                    else:
                        if mergeConflicts:
                            status = 'conflicts'
                            color = 'darkgoldenrod'
                        else:
                            status = 'ok'
                            color = 'darkgreen'

                    self.write('<span style="color: {}">{}</span><br>'.format(color, status))

    class StoreHandler(tornado.web.RequestHandler, ResponseTemplates):

        def receiveMetadata(self):
            log.info('Receiving asset store metadata')

            server = self.settings['server']
            conn = server.initHTTPSConnection(CDN_HOST, 443)

            metaURL = '/demo/asset_store_meta.json'
            if server.getLicenseInfo()['releaseIsPreview']:
                metaURL = '/demo_pre/asset_store_meta.json'

            try:
                conn.request('GET', metaURL)
                resp = conn.getresponse()
            except:
                log.error('GET asset_store_meta.json Connection refused')
                return None

            if resp.status != 200:
                log.error('GET asset_store_meta.json {}'.format(resp.status, resp.reason))
                return None

            try:
                demos = json.loads(resp.read().decode())
                return demos
            except socket.timeout:
                log.error('GET asset_store_meta.json Connection timed out')
                return None

        def getCurrentAndLatestVersions(self):
            log.info('Receiving version info')

            server = self.settings['server']
            conn = server.initHTTPSConnection(WWW_HOST, 443, 1)

            try:
                conn.request('GET', '/verge3d-' + server.flavor.lower() + '-latest.json')
                resp = conn.getresponse()
            except:
                log.warning('GET version info: Connection refused')
                return None, None

            if resp.status != 200:
                log.error('GET version info: {}'.format(resp.status, resp.reason))
                return None, None

            try:
                currentVersion = server.getLicenseInfo()['releaseVersion']
                currentVersion = re.findall(r'\d*\.\d*', currentVersion)[0]

                info = json.loads(resp.read().decode())
                latestVersion = info['version']
                latestVersion = re.findall(r'\d*\.\d*', latestVersion)[0]

                return currentVersion, latestVersion

            except socket.timeout:
                log.error('GET version info: Connection timed out')
                return None, None

        async def receiveDemo(self, url):

            server = self.settings['server']
            distDir = pathlib.Path(server.serverTempDir) / DIST_TMP_DIR

            demoZipName = os.path.basename(url)

            log.info('Fetching demo zip: ' + url)
            await server.downloadFileAsync(url, distDir / demoZipName)

            noTrace = lambda *p, **k: None
            ziptools.extractzipfile(distDir / demoZipName, server.getExtAppsDir(), permissions=True, trace=noTrace)

            return os.path.splitext(demoZipName)[0]

        async def get(self, req='status'):

            if req not in ['status', 'download', 'cancel']:
                self.set_status(400)
                self.writeError('Bad store request', False)
                return

            server = self.settings['server']
            server.loadSettings()

            if req == 'status':

                demos = self.receiveMetadata()
                if not demos:
                    self.writeError('Failed to receive Verge3D Asset Store metadata, possibly due to connection error.', False)
                    return

                self.write(self.renderTemplate('store.tpl', {
                    'demos': demos,
                    'appTemplates': server.amSettings['appTemplates'],
                    'theme': server.amSettings['theme'],
                    'manualURL': server.amSettings['manualURL'],
                    'simplifiedMobileUI': server.amSettings['simplifiedMobileUI'],
                    'licenseInfo': server.getLicenseInfo(),
                    'productName': server.getProductName(),
                    'package': self.getReqModPack(),
                    'jsContext': self.getJSContext()
                }))

            elif req == 'download':
                try:
                    demoURL = self.get_argument('demo')
                except tornado.web.MissingArgumentError:
                    self.writeError('Please specify demo URL to download')
                    return

                appName = os.path.splitext(os.path.basename(demoURL))[0]
                for appInfo in server.findApps():
                    if appInfo['name'] == appName:
                        self.writeError('Application with such name already exists.')
                        return

                currentVersion, latestVersion = self.getCurrentAndLatestVersions()
                if currentVersion and latestVersion and currentVersion < latestVersion:
                    self.writeError('<div class="dialog-text-center red">This app is not compatible with your Verge3D version!</div><div class="dialog-text-center">Please upgrade to v{} (you currently have v{}).</div>'.format(latestVersion, currentVersion))
                    return

                demoName = await self.receiveDemo(demoURL)

                self.write(self.renderTemplate('dialog_store_download_done.tpl', {
                    'nameDisp': server.appTitle(demoName),
                    'manageURL': server.manageURL(demoName)
                }))

            elif req == 'cancel':
                pass

    class SettingsHandler(tornado.web.RequestHandler, ResponseTemplates):
        def get(self):
            server = self.settings['server']
            self.write(self.renderTemplate('dialog_settings.tpl', {
                'settings' : server.amSettings,
                'manualURL': server.amSettings['manualURL'] # duplicate to simplify template logic
            }))

    class SaveSettingsHandler(tornado.web.RequestHandler, ResponseTemplates):
        def post(self, tail=None):
            saveBySplash = self.get_argument('splash', False)

            settings = json.loads(self.request.body)
            server = self.settings['server']
            server.amSettings.update(settings)
            server.saveSettings(saveBySplash)

    class DoShowSplashHandler(tornado.web.RequestHandler, ResponseTemplates):
        def get(self):
            server = self.settings['server']
            licenseInfo = server.getLicenseInfo()

            if (server.amSettings['newSettings'] or
                    licenseInfo['releaseVersion'] != server.amSettings['lastReleaseVersion']):
                self.write('1')
            else:
                self.write('0')

    class SplashScreenHandler(tornado.web.RequestHandler, ResponseTemplates):
        def get(self):
            server = self.settings['server']
            licenseInfo = server.getLicenseInfo()

            self.write(self.renderTemplate('dialog_splash.tpl', {
                'settings' : server.amSettings,
                'manualURL': server.amSettings['manualURL'],
                'ineligibleVersion': licenseInfo['ineligibleVersion'],
                'version': re.findall(r'\d*\.\d*', licenseInfo['releaseVersion'])[0]
            }))

    class GetManualURLHandler(tornado.web.RequestHandler, ResponseTemplates):
        def get(self):
            server = self.settings['server']
            self.write(server.amSettings['manualURL'])

    class CreateNativeAppHandler(tornado.web.RequestHandler, ResponseTemplates):
        def get(self):
            try:
                app = self.get_argument('app')
            except tornado.web.MissingArgumentError:
                self.writeError('Please specify application filepath')
                self.set_status(400)
                return

            server = self.settings['server']
            appInfo = server.findApp(app)

            nativeAppSettings = {}

            try:
                with open(appInfo['appDir'] / APP_DATA_DIR / NATIVE_APP_SETTINGS_JSON, 'r', encoding='utf-8') as f:
                    nativeAppSettings = json.load(f)
            except OSError:
                pass
            except json.decoder.JSONDecodeError:
                log.error('Native app settings decoding error')

            nativeAppSettings.setdefault('templateName', 'electron')
            nativeAppSettings.setdefault('targetPlatform', 'win32-x64')
            nativeAppSettings.setdefault('appName', appInfo['title'])
            nativeAppSettings.setdefault('appID', 'com.example.' + appInfo['title'].replace(' ', ''))
            nativeAppSettings.setdefault('appVersion', '0.1.0')
            nativeAppSettings.setdefault('appDescription', 'My Awesome App')
            nativeAppSettings.setdefault('authorName', 'Tony Stark')
            nativeAppSettings.setdefault('authorEmail', 'author@example.com')
            nativeAppSettings.setdefault('authorWebsite', 'https://www.example.com')
            nativeAppSettings.setdefault('showFullscreen', 'false')

            subs = {
                'app': appInfo['name'],
                'manualURL': server.amSettings['manualURL'],
                'showMacPlatforms': os.name != 'nt', # not supported on Windows
            }
            subs.update(nativeAppSettings)

            self.write(self.renderTemplate('dialog_create_native_app.tpl', subs))

    class CreateNativeAppDoHandler(tornado.web.RequestHandler, ResponseTemplates):

        def saveNativeAppSettings(self, appDir, settings):
            appDataDir = appDir / APP_DATA_DIR
            appDataDir.mkdir(exist_ok=True)

            with open(appDataDir / NATIVE_APP_SETTINGS_JSON, 'w', encoding='utf-8') as f:
                json.dump(settings, f, sort_keys=True, indent=4, separators=(', ', ': '), ensure_ascii=False)

        async def receiveElectronBinary(self, targetPlatform):

            server = self.settings['server']
            distDir = server.getConfigDir()

            assetName = 'electron-{0}-{1}.zip'.format(ELECTRON_RELEASE, targetPlatform)

            if not (distDir / assetName).exists():
                log.info('Fetching Electron binary: {}'.format(assetName))
                await server.downloadFileAsync(CDN_URL + 'electron/{0}/{1}'.format(ELECTRON_RELEASE, assetName),
                                               distDir / assetName)
            return distDir / assetName

        async def post(self):
            try:
                app = self.get_argument('app')
                appTemplate = self.get_argument('templateName')
            except tornado.web.MissingArgumentError:
                self.writeError('Please specify app to create template for')
                return

            server = self.settings['server']

            appInfo = server.findApp(app)
            if not appInfo:
                self.writeError('Could not find app: {0}.'.format(app))
                return

            rootDir = server.getRootDir()

            appDir = appInfo['appDir']

            zipPath = pathlib.Path(server.serverTempDir) / DIST_TMP_DIR / (appDir.name + '.zip')

            appName = self.get_argument('appName')
            if not appName:
                self.writeError('Missing app name')
                return

            appID = self.get_argument('appID')
            if not appID:
                self.writeError('Missing app ID')
                return

            targetPlatform = self.get_argument('targetPlatform') # electron only

            appVersion = self.get_argument('appVersion')
            appDescription = self.get_argument('appDescription')
            authorName = self.get_argument('authorName')

            authorEmail = self.get_argument('authorEmail') # cordova only
            authorWebsite = self.get_argument('authorWebsite') # cordova only
            showFullscreen = self.get_argument('showFullscreen', default='false') # cordova only

            self.saveNativeAppSettings(appDir, {
                'templateName': appTemplate,
                'targetPlatform': targetPlatform,
                'appName': appName,
                'appID': appID,
                'appVersion': appVersion,
                'appDescription': appDescription,
                'authorName': authorName,
                'authorEmail': authorEmail,
                'authorWebsite': authorWebsite,
                'showFullscreen': showFullscreen,
            })

            mainHTML = os.path.join(appDir, 'index.html')
            if not os.path.exists(mainHTML) and appInfo['html']:
                mainHTML = server.findMainFile(appInfo['name'], appInfo['html'])
            mainHTML = os.path.basename(mainHTML)

            noTrace = lambda *p, **k: None

            with tempfile.TemporaryDirectory() as tmpDir:
                tmpDir = pathlib.Path(tmpDir)

                if appTemplate == 'cordova':
                    log.info('Creating Cordova app template')

                    server.copyTreeMerge(rootDir / 'manager' / 'templates' / 'Cordova', tmpDir)
                    server.copyTreeMerge(appDir, tmpDir / 'www', APP_SRC_IGNORE)

                    server.replaceTemplateStrings(tmpDir / 'config.xml', {
                        'appName': appName,
                        'appDescription': appDescription,
                        'appID': appID,
                        'appVersion': appVersion,
                        'authorName': authorName,
                        'authorEmail': authorEmail,
                        'authorWebsite': authorWebsite,
                        'mainHTML': mainHTML
                    })

                    server.replaceTemplateStrings(tmpDir / 'package.json', {
                        'appName': appName,
                        'appVersion': appVersion,
                        'appDescription': appDescription,
                        'appID': appID,
                        'authorName': authorName
                    })

                    mainHTMLDest = tmpDir / 'www' / mainHTML
                    if mainHTMLDest.exists():
                        server.replaceTemplateStrings(mainHTMLDest, '</head>',
                                '<script src="cordova.js"></script>\n</head>')

                    icon512 = server.getManifestParam(tmpDir / 'www', 'favicon512')
                    if icon512:
                        log.info('Creating Cordova template icons')
                        icon512 = tmpDir / 'www' / 'media' / icon512

                        try:
                            if platform.system() == 'Windows':
                                convertPath = os.path.join(BASE_DIR, 'lib', 'imagemagick', 'windows_amd64', 'convert.exe')
                            elif platform.system() == 'Darwin':
                                if os.path.exists('/opt/homebrew/bin/convert'): # ARM
                                    convertPath = '/opt/homebrew/bin/convert'
                                elif os.path.exists('/usr/local/bin/convert'): # Intel
                                    convertPath = '/usr/local/bin/convert'
                                else:
                                    convertPath = 'convert'
                            else:
                                convertPath = 'convert'

                            out = server.runPython([os.path.join(BASE_DIR, 'lib', 'icon_generator', 'generate.py'),
                                                    '--cordova-icon', str(icon512),
                                                    '--destination', str(tmpDir),
                                                    '--convert-path', convertPath])
                            server.writeOutStream(out.decode('utf-8'))

                        except subprocess.CalledProcessError as e:
                            server.writeOutStream(e.output.decode('utf-8'))

                elif appTemplate == 'electron':
                    log.info('Creating Electron app template')

                    if targetPlatform != 'none':
                        try:
                            electronBinary = await self.receiveElectronBinary(targetPlatform)
                        except:
                            self.writeError('Failed to receive Electron binary, check your internet connection')
                            return

                        ziptools.extractzipfile(electronBinary, tmpDir, permissions=True, trace=noTrace)

                        if targetPlatform in ['darwin-x64', 'darwin-arm64']:

                            macAppName = appID.split('.')[-1] + '.app'

                            os.rename(tmpDir / 'Electron.app', tmpDir / macAppName)

                            plist1 = tmpDir / macAppName / 'Contents' / 'Info.plist'
                            plist2 = (tmpDir / macAppName / 'Contents' / 'Frameworks' /
                                      'Electron Helper.app' / 'Contents' / 'Info.plist')

                            pl = None

                            with open(plist1, 'rb') as fp:
                               pl = plistlib.load(fp)

                            pl['CFBundleDisplayName'] = appName
                            pl['CFBundleShortVersionString'] = appVersion
                            pl['CFBundleIconFile'] = 'verge3d.icns'
                            pl['CFBundleIdentifier'] = appID
                            pl['CFBundleName'] = appID.split('.')[-1]

                            with open(plist1, 'wb') as fp:
                                plistlib.dump(pl, fp)

                            with open(plist2, 'rb') as fp:
                                pl = plistlib.load(fp)

                            pl['CFBundleIdentifier'] = appID + '.helper'
                            pl['CFBundleName'] = appID.split('.')[-1] + ' Helper'

                            with open(plist2, 'wb') as fp:
                                plistlib.dump(pl, fp)

                            shutil.copy2(rootDir / 'manager' / 'dist' / 'verge3d.icns',
                                         tmpDir / macAppName / 'Contents' / 'Resources')

                            tplDest = tmpDir / macAppName / 'Contents' / 'Resources' / 'app'

                        elif targetPlatform in ['win32-x64', 'win32-ia32', 'win32-arm64']:

                            server.runPython([os.path.join(BASE_DIR, 'lib', 'peresed.py'),
                                            '-A',
                                            os.path.join(BASE_DIR, 'dist', 'verge3d.res'),
                                            os.path.join(tmpDir, 'electron.exe')])

                            server.runPython([os.path.join(BASE_DIR, 'lib', 'peresed.py'),
                                            '-V',
                                            'ProductName={}'.format(appName),
                                            os.path.join(tmpDir, 'electron.exe')])

                            server.runPython([os.path.join(BASE_DIR, 'lib', 'peresed.py'),
                                            '-V',
                                            'FileDescription={}'.format(appDescription),
                                            os.path.join(tmpDir, 'electron.exe')])

                            server.runPython([os.path.join(BASE_DIR, 'lib', 'peresed.py'),
                                            '-V',
                                            'LegalCopyright={}'.format(authorName),
                                            os.path.join(tmpDir, 'electron.exe')])

                            server.runPython([os.path.join(BASE_DIR, 'lib', 'peresed.py'),
                                            '-V',
                                            'ProductVersion={}'.format(appVersion),
                                            os.path.join(tmpDir, 'electron.exe')])

                            peVersion = re.sub(r'[^\d\.,]', '', appVersion)

                            server.runPython([os.path.join(BASE_DIR, 'lib', 'peresed.py'),
                                            '-V',
                                            'FileVersion={}'.format(peVersion),
                                            os.path.join(tmpDir, 'electron.exe')])

                            os.rename(tmpDir / 'electron.exe', tmpDir / (appDir.name + '.exe'))
                            tplDest = tmpDir / 'resources' / 'app'

                        else:

                            os.rename(tmpDir / 'electron', tmpDir / appDir.name)
                            tplDest = tmpDir / 'resources' / 'app'

                    else:
                        tplDest = tmpDir

                    server.copyTreeMerge(rootDir / 'manager' / 'templates' / 'Electron', tplDest)
                    server.copyTreeMerge(appDir, tplDest, APP_SRC_IGNORE)

                    server.replaceTemplateStrings(tplDest / 'main.js', {
                        'mainHTML': mainHTML,
                        'showFullscreen': showFullscreen
                    })

                    server.replaceTemplateStrings(tplDest / 'package.json', {
                        'appName': appName,
                        'appVersion': appVersion,
                        'appDescription': appDescription,
                        'authorName': authorName
                    })

                else:
                    self.writeError('Wrong app template: {0}.'.format(appTemplate))
                    return

                log.info('Packing template ZIP: {}'.format(zipPath))

                await server.runAsync(ziptools.createzipfile, zipPath, [str(p) for p in tmpDir.glob('*')],
                                       zipat=appDir.name, trace=noTrace)

            url = server.pathToUrl(zipPath.relative_to(server.serverTempDir), makeAbs=True)

            self.write(self.renderTemplate('dialog_create_native_app_done.tpl', {
                'downloadURL': url
            }))

    class CreateScormHandler(tornado.web.RequestHandler, ResponseTemplates):
        def get(self):
            try:
                app = self.get_argument('app')
            except tornado.web.MissingArgumentError:
                self.writeError('Please specify application filepath')
                self.set_status(400)
                return

            server = self.settings['server']
            appInfo = server.findApp(app)

            scormSettings = {}

            try:
                with open(appInfo['appDir'] / APP_DATA_DIR / SCORM_SETTINGS_JSON, 'r', encoding='utf-8') as f:
                    scormSettings = json.load(f)
            except OSError:
                pass
            except json.decoder.JSONDecodeError:
                log.error('SCORM settings decoding error')

            scormSettings.setdefault('courseName', appInfo['title'])
            scormSettings.setdefault('courseID', 'com.example.' + appInfo['title'].replace(' ', ''))
            scormSettings.setdefault('defaultItemTitle', 'Run App')

            subs = {
                'app': appInfo['name'],
                'manualURL': server.amSettings['manualURL'],
            }
            subs.update(scormSettings)

            self.write(self.renderTemplate('dialog_create_scorm.tpl', subs))

    class CreateScormDoHandler(tornado.web.RequestHandler, ResponseTemplates):

        def saveScormSettings(self, appDir, settings):
            appDataDir = appDir / APP_DATA_DIR
            appDataDir.mkdir(exist_ok=True)

            with open(appDataDir / SCORM_SETTINGS_JSON, 'w', encoding='utf-8') as f:
                json.dump(settings, f, sort_keys=True, indent=4, separators=(', ', ': '), ensure_ascii=False)

        async def post(self):
            try:
                app = self.get_argument('app')
            except tornado.web.MissingArgumentError:
                self.writeError('Please specify app to create template for')
                return

            server = self.settings['server']

            appInfo = server.findApp(app)
            if not appInfo:
                self.writeError('Could not find app: {0}.'.format(app))
                return

            rootDir = server.getRootDir()

            appDir = appInfo['appDir']
            zipPath = pathlib.Path(server.serverTempDir) / DIST_TMP_DIR / (appDir.name + '.zip')

            courseName = self.get_argument('courseName')
            if not courseName:
                self.writeError('Missing course name')
                return

            courseID = self.get_argument('courseID')
            if not courseID:
                self.writeError('Missing course ID')
                return

            defaultItemTitle = self.get_argument('defaultItemTitle')

            self.saveScormSettings(appDir, {
                'courseName': courseName,
                'courseID': courseID,
                'defaultItemTitle': defaultItemTitle
            })

            mainHTML = os.path.join(appDir, 'index.html')

            if not os.path.exists(mainHTML):
                if appInfo['html']:
                    mainHTML = server.findMainFile(appInfo['name'], appInfo['html'])

            mainHTML = os.path.basename(mainHTML)

            noTrace = lambda *p, **k: None

            with tempfile.TemporaryDirectory() as tmpDir:
                tmpDir = pathlib.Path(tmpDir)

                log.info('Creating SCORM package')

                server.copyTreeMerge(rootDir / 'manager' / 'templates' / 'SCORM', tmpDir)
                server.copyTreeMerge(appDir, tmpDir / 'app', APP_SRC_IGNORE)

                scoItems = []
                resourceItems = []

                for p in filter(lambda p: p.is_file(), tmpDir.rglob('*')):
                    if p.name == LOGIC_JS:
                        with open(p, 'r', encoding='utf-8') as f:
                            lines = f.readlines()
                            for line in lines:
                                m = re.search('__V3D_SCORM_ITEM__(.*)', line)
                                if m:
                                    j = json.loads(m.group(1))
                                    scoItems.append(j)

                    resourceItems.append(str(p.relative_to(tmpDir)))

                server.replaceTemplateStrings(tmpDir / 'imsmanifest.xml', {
                    'courseName': courseName,
                    'courseID': courseID,
                    'courseMainHTML': 'app/' + mainHTML,
                    'defaultItemTitle': defaultItemTitle,
                    'scoItems': scoItems,
                    'resourceItems': resourceItems
                })

                log.info('Packing course ZIP: {}'.format(zipPath))

                await server.runAsync(ziptools.createzipfile, zipPath, [str(p) for p in tmpDir.glob('*')],
                                      zipat='.', trace=noTrace)

            url = server.pathToUrl(zipPath.relative_to(server.serverTempDir), makeAbs=True)

            self.write(self.renderTemplate('dialog_create_scorm_done.tpl', {
                'downloadURL': url
            }))

    class PingHandler(tornado.web.RequestHandler, ResponseTemplates):
        def get(self):
            self.write('PONG')

    class StopServerHandler(tornado.web.RequestHandler, ResponseTemplates):
        def get(self):
            self.settings['server'].stop()
            self.redirect('/')

    class RestartServerHandler(tornado.web.RequestHandler, ResponseTemplates):
        def get(self):
            self.settings['server'].stop(True)
            self.redirect('/')

    class ResetServerHandler(tornado.web.RequestHandler, ResponseTemplates):
        def get(self):
            server = self.settings['server']

            cfgDir = server.getConfigDir()

            if cfgDir.exists():
                shutil.rmtree(cfgDir)

            self.settings['server'].stop(True)
            self.redirect('/')

    class PuzzlesPluginHandler(tornado.web.RequestHandler, ResponseTemplates):
        def get(self):
            pluginList = []

            server = self.settings['server']
            puzzlesDir = server.getRootDir() / PUZZLES_DIR
            pluginsDir = puzzlesDir / PLUGINS_DIR

            if os.path.isdir(pluginsDir):
                for pluginPath in sorted(pathlib.Path(pluginsDir).iterdir()):
                    if pluginPath.is_dir():

                        initFilePath = pluginPath / PUZZLE_PLUGIN_INIT_FILE_NAME
                        if initFilePath.is_file():

                            pluginInfo = {
                                'name': pluginPath.name,
                                'url': str(initFilePath.relative_to(puzzlesDir)),
                                'blocks': {}
                            }

                            for blockPath in sorted(pluginPath.glob('./*.%s' % PUZZLE_PLUGIN_BLOCK_FILE_EXT)):
                                if blockPath.is_file():
                                    pluginInfo['blocks'][blockPath.stem] = {
                                        'url': str(blockPath.relative_to(puzzlesDir))
                                    }

                            pluginList.append(pluginInfo)

            self.finish({ 'pluginList': pluginList })

    class TestAppListHandler(tornado.web.RequestHandler, ResponseTemplates):
        def set_default_headers(self):
            self.set_header("Content-Type", 'application/json')

        def get(self):
            appList = []

            server = self.settings['server']

            for appInfo in server.findApps():
                appViewInfo = server.genAppViewInfo(appInfo)
                item = {
                    'name': appViewInfo['name'],
                    'html': [h['url'] for h in appViewInfo['html']],
                    'gltf': [g['url'] for g in appViewInfo['gltf']],
                }

                if appViewInfo['puzzles']:
                    item['puzzles'] = appViewInfo['puzzles']['urls'][0]

                appList.append(item)

            self.finish(json.dumps({'appList': appList}))

    class SelectDirHandler(tornado.web.RequestHandler, ResponseTemplates):
        def get(self):
            d = native_dialogs.selectDir('Please select Verge3D apps folder')
            self.write(d)

    class GetPreviewDirHandler(tornado.web.RequestHandler, ResponseTemplates):
        def get(self):
            server = self.settings['server']
            self.write(os.path.join(server.serverTempDir, PREVIEW_TMP_DIR))

    class GetEnginePathHandler(tornado.web.RequestHandler, ResponseTemplates):
        def get(self):
            server = self.settings['server']
            self.write(str(server.getModulePath('v3d.js')))

    class IsRuntimeOutdatedHandler(tornado.web.RequestHandler, ResponseTemplates):
        def get(self):
            server = self.settings['server']
            licenseInfo = server.getLicenseInfo()

            if server.runtimeReleaseVersion != licenseInfo['releaseVersion']:
                self.write('1')
            else:
                self.write('0')

    class ConnectionErrorHandler(tornado.web.RequestHandler, ResponseTemplates):
        def get(self):
            self.write(self.renderTemplate('connection_error.tpl', {
                'jsContext': self.getJSContext()
            }))

    class LogHandler(tornado.web.RequestHandler, ResponseTemplates):
        def get(self):
            server = self.settings['server']
            self.write('<pre>')
            self.write('<br>'.join(server.logMemStream.getvalue().splitlines()))
            self.write('</pre>')

    class StaticHandler(tornado.web.StaticFileHandler):
        def set_extra_headers(self, path):

            if path.startswith(os.path.join(PUZZLES_DIR, 'media')):
                return

            self.set_header('Cache-Control', 'no-cache, no-store, must-revalidate, max-age=0')
            self.set_header('Pragma', 'no-cache')
            self.set_header('Expires', '0')
            now = datetime.datetime.now()
            expiration = now - datetime.timedelta(days=365)
            self.set_header('Last-Modified', expiration)

            self.set_header('Service-Worker-Allowed', '/')

        def get_absolute_path(self, root, path):
            if not DEBUG and path.endswith(os.path.join('build', 'v3d.js')):
                modName = os.path.basename(path)
                server = self.settings['server']
                cfgDir = server.getConfigDir()
                if (cfgDir / modName).exists():
                    return str(cfgDir / modName)
                else:
                    return super().get_absolute_path(root, path)
            else:
                return super().get_absolute_path(root, path)

        def validate_absolute_path(self, root, absolute_path):
            if not DEBUG and absolute_path.endswith(os.sep + 'v3d.js'):
                return absolute_path
            else:
                return super().validate_absolute_path(root, absolute_path)

    def start(self):

        log.info('Starting App Manager server ({0})'.format(self.getProductName()))
        log.info('Serving network port{}: {}'.format('s' if len(self.ports) > 1 else '', ','.join([str(p) for p in self.ports])))
        log.info('System: {}'.format(platform.platform()))
        log.info('Python: {}'.format(sys.version.replace('\n', '')))
        log.info('Executable: {}'.format(sys.executable))
        log.info('Filesystem encoding: {}'.format(sys.getfilesystemencoding()))

        self.transferProgress = 0;

        self.loadSettings()
        self.handleVersionUpdate()

        rootDir = self.getRootDir()

        log.info('Root folder: {}'.format(str(rootDir)))
        log.info('Applications folder: {}'.format(self.amSettings['extAppsDirectory'] or 'not specified'))
        log.info('Config folder: {}'.format(self.getConfigDir()))
        log.info('Network cache age: {} minutes'.format(self.amSettings['cacheMaxAge']))

        if self.amSettings['proxyHost'] and self.amSettings['proxyPort']:
            log.info('Proxy Host: {}'.format(self.amSettings['proxyHost']))
            log.info('Proxy Port: {}'.format(self.amSettings['proxyPort']))
            log.info('Proxy User: {}'.format('*' * len(self.amSettings['proxyUser'])))
            log.info('Proxy Pass: {}'.format('*' * len(self.amSettings['proxyPass'])))

        libXMLPath = self.getExtAppsDir() / LIBRARY_XML
        if not os.path.exists(libXMLPath):
            try:
                log.warning('Puzzles library file not found, creating one')
                f = open(libXMLPath, 'w', encoding='utf-8', newline='\n')
                f.write('<?xml version="1.0" ?><xml/>')
                f.close()
            except OSError:
                log.error('Access denied: Puzzles library file was not created')
                pass

        app = self.createTornadoApp()
        try:
            if not self.asyncioLoop:
                self.asyncioLoop = asyncio.new_event_loop()
                asyncio.set_event_loop(self.asyncioLoop)

            address = '0.0.0.0' if self.amSettings['externalInterface'] else 'localhost'

            httpServers = []
            for port in self.ports:
                httpServers.append(app.listen(port, address=address, max_body_size=SERVER_MAX_BODY_SIZE))
        except OSError:
            log.error('Address already in use, exiting')
            self.cleanupPreviewDir()
            return
        else:
            self.ioloop = tornado.ioloop.IOLoop.current()
            self.ioloop.start()

            for s in httpServers:
                s.stop()
                self.ioloop.run_sync(s.close_all_connections, timeout=5)

            self.cleanupPreviewDir()

            if self.needRestart:
                self.start()
                self.needRestart = False

    def cleanupPreviewDir(self):
        if self.serverTempDir and os.path.exists(self.serverTempDir):
            shutil.rmtree(self.serverTempDir)
            self.serverTempDir = None

    def stop(self, needRestart=False):
        log.info('Schedule App Manager server {0}'.format('restart' if needRestart else 'stop'))

        self.needRestart = needRestart

        if self.ioloop:
            self.ioloop.add_callback(self.ioloop.stop)

    def createTornadoApp(self):
        root = os.path.join(os.path.dirname(__file__), '..')

        handlers = [
            (r'/?$', self.RootHandler),
            (r'/create/?$', self.CreateAppHandler),
            (r'/manage/?$', self.ManageAppHandler),
            (r'/app_settings/?$', self.AppSettingsHandler),
            (r'/app_settings/save?$', self.AppSettingsSaveHandler),
            (r'/app_settings/save_image?$', self.AppSettingsSaveImageHandler),
            (r'/app_settings/update_worker?$', self.AppSettingsUpdateWorkerHandler),
            (r'/open/?$', self.OpenFileHandler),
            (r'/delete_confirm/?$', self.DeleteConfirmHandler),
            (r'/delete/?$', self.DeleteFileHandler),
            (r'/network/?$', self.ProcessNetworkHandler),
            (r'/network/([a-zA-Z]+)/?$', self.ProcessNetworkHandler),
            (r'/storage/xml/?$', self.SavePuzzlesHandler, {'isLibrary': False, 'libDelete': False}),
            (r'/storage/xml/library/?$', self.SavePuzzlesHandler, {'isLibrary': True, 'libDelete': False}),
            (r'/storage/xml/libdelete/?$', self.SavePuzzlesHandler, {'isLibrary': True, 'libDelete': True}),
            (r'/storage/create_app_zip/?$', self.CreateAppZipHandler),
            (r'/storage/drop_app_zip/?$', self.DropAppZipHandler),
            (r'/enterkey/?$', self.EnterKeyHandler),
            (r'/update_app_info/?$', self.UpdateAppInfoHandler),
            (r'/update_app/?$', self.UpdateAppHandler),
            (r'/update_all_apps/?$', self.UpdateAllAppsHandler),
            (r'/store/?$', self.StoreHandler),
            (r'/store/([a-zA-Z]+)/?$', self.StoreHandler),
            (r'/settings/?$', self.SettingsHandler),
            (r'/settings/save?$', self.SaveSettingsHandler),
            (r'/settings/do_show_splash?$', self.DoShowSplashHandler),
            (r'/settings/splash_screen?$', self.SplashScreenHandler),
            (r'/settings/get_manual_url?$', self.GetManualURLHandler),
            (r'/create_native_app/?$', self.CreateNativeAppHandler),
            (r'/create_native_app/do?$', self.CreateNativeAppDoHandler),
            (r'/create_scorm/?$', self.CreateScormHandler),
            (r'/create_scorm/do?$', self.CreateScormDoHandler),
            (r'/ping/?$', self.PingHandler),
            (r'/stop/?$', self.StopServerHandler),
            (r'/restart/?$', self.RestartServerHandler),
            (r'/reset/?$', self.ResetServerHandler),
            (r'/puzzles/plugin_list/?$', self.PuzzlesPluginHandler),
            (r'/test/app_list/?$', self.TestAppListHandler),
            (r'/select_dir/?$', self.SelectDirHandler),
            (r'/get_preview_dir/?$', self.GetPreviewDirHandler),
            (r'/get_engine_path/?$', self.GetEnginePathHandler),
            (r'/is_runtime_outdated/?$', self.IsRuntimeOutdatedHandler),
            (r'/connection_error/?$', self.ConnectionErrorHandler),
            (r'/log/?$', self.LogHandler),
            (r'/(.*)$', self.StaticHandler, {'path': root, 'default_filename': 'index.html'}),
        ]

        extAppsDir = self.getExtAppsDir()
        handlers.insert(-1, (r'{}(.*)$'.format(APPS_PREFIX), self.StaticHandler, {'path': str(extAppsDir), 'default_filename': 'index.html'}))

        self.serverTempDir = tempfile.mkdtemp(prefix='verge3d_tmp_')
        log.info('Temporary folder: {}'.format(self.serverTempDir))

        previewDir = os.path.join(self.serverTempDir, PREVIEW_TMP_DIR)
        os.makedirs(previewDir, exist_ok=True)
        handlers.insert(-1, (r'/sneak_peek/(.*)$', self.StaticHandler, {'path': previewDir}))

        distDir = os.path.join(self.serverTempDir, DIST_TMP_DIR)
        os.makedirs(distDir, exist_ok=True)
        handlers.insert(-1, (r'/dist/(.*)$', self.StaticHandler, {'path': distDir}))

        return tornado.web.Application(handlers, server=self)

    def signalHandler(self, signum, frame):
        self.writeOutStream('\n') # to print on new line after ^C
        tornado.ioloop.IOLoop.current().add_callback_from_signal(self.stop)

def printUsage(error=''):
    if error:
        print(error)
    print("""
Usage: server.py PACKAGE [RUN_BROWSER]

PACKAGE (mandatory) is one of BLENDER, MAX, MAYA, ALL.

RUN_BROWSER (optional) opens the App Manager in the system-default web browser. This feature works even if the App Manager server is already running.

Check out the Verge3D User Manual (v3d.net/m) for more info on running the App Manager via command line.

Examples:
    ./server.py BLENDER RUN_BROWSER
    python server.py MAX
    ./server.py MAYA
""")

if __name__ == '__main__':
    if len(sys.argv) > 1 and sys.argv[1].upper() in ['BLENDER', 'MAX', 'MAYA', 'ALL']:
        flavor = sys.argv[1].upper()
    else:
        printUsage('Please specify the modeling software the App Manager should operate with.')
        sys.exit(1)

    if len(sys.argv) > 2 and sys.argv[2].upper() == 'RUN_BROWSER':
        import webbrowser
        webbrowser.open('http://localhost:{}/'.format(PORTS[flavor][0]))

    server = AppManagerServer(flavor)

    signal.signal(signal.SIGTERM, server.signalHandler)
    signal.signal(signal.SIGINT, server.signalHandler)

    server.start()
