expanded Assets scripting interface

This commit is contained in:
humbletim 2017-12-15 14:12:04 -05:00
parent 15fcf66d0e
commit 8c7a8f0df3
12 changed files with 1090 additions and 47 deletions

View file

@ -20,6 +20,7 @@
#include <QtNetwork/QNetworkDiskCache>
#include <shared/GlobalAppProperties.h>
#include <shared/MiniPromises.h>
#include "AssetRequest.h"
#include "AssetUpload.h"
@ -72,6 +73,30 @@ void AssetClient::init() {
networkAccessManager.setCache(cache);
qInfo() << "ResourceManager disk cache setup at" << _cacheDir
<< "(size:" << MAXIMUM_CACHE_SIZE / BYTES_PER_GIGABYTES << "GB)";
} else {
auto cache = qobject_cast<QNetworkDiskCache*>(networkAccessManager.cache());
qInfo() << "ResourceManager disk cache already setup at" << cache->cacheDirectory()
<< "(size:" << cache->maximumCacheSize() / BYTES_PER_GIGABYTES << "GB)";
}
}
void AssetClient::cacheInfoRequest(MiniPromise::Promise deferred) {
if (QThread::currentThread() != thread()) {
if (!QMetaType::isRegistered(qMetaTypeId<MiniPromise::Promise>())) {
qRegisterMetaType<MiniPromise::Promise>();
}
QMetaObject::invokeMethod(this, "cacheInfoRequest", Q_ARG(MiniPromise::Promise, deferred));
return;
}
if (auto* cache = qobject_cast<QNetworkDiskCache*>(NetworkAccessManager::getInstance().cache())) {
deferred->resolve({
{ "cacheDirectory", cache->cacheDirectory() },
{ "cacheSize", cache->cacheSize() },
{ "maximumCacheSize", cache->maximumCacheSize() },
});
} else {
deferred->reject("Cache not available");
}
}

View file

@ -19,6 +19,7 @@
#include <map>
#include <DependencyManager.h>
#include <shared/MiniPromises.h>
#include "AssetUtils.h"
#include "ByteRange.h"
@ -66,6 +67,7 @@ public slots:
void init();
void cacheInfoRequest(QObject* reciever, QString slot);
void cacheInfoRequest(MiniPromise::Promise deferred);
void clearCache();
private slots:

View file

@ -134,3 +134,14 @@ void AssetRequest::start() {
emit progress(totalReceived, total);
});
}
const QString AssetRequest::getErrorString() const {
QString result;
if (_error != Error::NoError) {
QVariant v;
v.setValue(_error);
result = v.toString(); // courtesy of Q_ENUM
}
return result;
}

View file

@ -42,7 +42,7 @@ public:
NetworkError,
UnknownError
};
Q_ENUM(Error)
AssetRequest(const QString& hash, const ByteRange& byteRange = ByteRange());
virtual ~AssetRequest() override;
@ -51,6 +51,7 @@ public:
const QByteArray& getData() const { return _data; }
const State& getState() const { return _state; }
const Error& getError() const { return _error; }
const QString getErrorString() const;
QUrl getUrl() const { return ::getATPUrl(_hash); }
QString getHash() const { return _hash; }

View file

@ -146,7 +146,7 @@ void AssetResourceRequest::requestHash(const AssetHash& hash) {
_assetRequest = assetClient->createRequest(hash, _byteRange);
connect(_assetRequest, &AssetRequest::progress, this, &AssetResourceRequest::onDownloadProgress);
connect(_assetRequest, &AssetRequest::finished, this, [this](AssetRequest* req) {
connect(_assetRequest, &AssetRequest::finished, this, [this, hash](AssetRequest* req) {
Q_ASSERT(_state == InProgress);
Q_ASSERT(req == _assetRequest);
Q_ASSERT(req->getState() == AssetRequest::Finished);

View file

@ -15,6 +15,7 @@
#include <QtCore/QCryptographicHash>
#include <QtCore/QDateTime>
#include <QtCore/QFileInfo> // for baseName
#include <QtNetwork/QAbstractNetworkCache>
#include "NetworkAccessManager.h"
@ -22,8 +23,36 @@
#include "ResourceManager.h"
QUrl getATPUrl(const QString& hash) {
return QUrl(QString("%1:%2").arg(URL_SCHEME_ATP, hash));
// Extract the valid AssetHash portion from atp: URLs like "[atp:]HASH[.fbx][?query]"
// (or an invalid AssetHash if not found)
AssetHash extractAssetHash(const QString& input) {
if (isValidHash(input)) {
return input;
}
QString path = getATPUrl(input).path();
QString baseName = QFileInfo(path).baseName();
if (isValidHash(baseName)) {
return baseName;
}
return AssetHash();
}
// Get the normalized ATP URL for a raw hash, /path or "atp:" input string.
QUrl getATPUrl(const QString& input) {
QUrl url = input;
if (!url.scheme().isEmpty() && url.scheme() != URL_SCHEME_ATP) {
return QUrl();
}
// this strips extraneous info from the URL (while preserving fragment/querystring)
QString path = url.toEncoded(
QUrl::RemoveAuthority | QUrl::RemoveScheme |
QUrl::StripTrailingSlash | QUrl::NormalizePathSegments
);
QString baseName = QFileInfo(path).baseName();
if (isValidPath(path) || isValidHash(baseName)) {
return QUrl(QString("%1:%2").arg(URL_SCHEME_ATP).arg(path));
}
return QUrl();
}
QByteArray hashData(const QByteArray& data) {

View file

@ -71,7 +71,8 @@ struct MappingInfo {
using AssetMapping = std::map<AssetPath, MappingInfo>;
QUrl getATPUrl(const QString& hash);
QUrl getATPUrl(const QString& input);
AssetHash extractAssetHash(const QString& input);
QByteArray hashData(const QByteArray& data);
@ -84,4 +85,18 @@ bool isValidHash(const QString& hashString);
QString bakingStatusToString(BakingStatus status);
// backwards-compatible namespace aliases
// (allows new code to be explicit -- eg: `AssetUtils::isValidPath(path)` vs. `isValidPath(path)`)
namespace AssetUtils {
static const auto& loadFromCache = ::loadFromCache;
static const auto& saveToCache = ::saveToCache;
static const auto& hashData = ::hashData;
static const auto& getATPUrl = ::getATPUrl;
static const auto& extractAssetHash = ::extractAssetHash;
static const auto& isValidFilePath = ::isValidFilePath;
static const auto& isValidPath = ::isValidPath;
static const auto& isValidHash = ::isValidHash;
};
#endif // hifi_AssetUtils_h

View file

@ -0,0 +1,399 @@
//
// BaseAssetScriptingInterface.cpp
// libraries/networking/src
//
// Copyright 2017 High Fidelity, Inc.
//
// Distributed under the Apache License, Version 2.0.
// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html
#include "BaseAssetScriptingInterface.h"
#include <QJsonDocument>
#include <QJsonArray>
#include <QMimeDatabase>
#include <QThread>
#include "AssetRequest.h"
#include "AssetUpload.h"
#include "AssetUtils.h"
#include "MappingRequest.h"
#include "NetworkLogging.h"
#include <RegisteredMetaTypes.h>
#include <shared/QtHelpers.h>
#include "Gzip.h"
using Promise = MiniPromise::Promise;
QSharedPointer<AssetClient> BaseAssetScriptingInterface::assetClient() {
return DependencyManager::get<AssetClient>();
}
BaseAssetScriptingInterface::BaseAssetScriptingInterface(QObject* parent) : QObject(parent), _cache(this) {
}
bool BaseAssetScriptingInterface::initializeCache() {
qDebug() << "BaseAssetScriptingInterface::getCacheStatus -- current values" << _cache.cacheDirectory() << _cache.cacheSize() << _cache.maximumCacheSize();
// NOTE: *instances* of QNetworkDiskCache are not thread-safe -- however, different threads can effectively
// use the same underlying cache if configured with identical settings. Once AssetClient's disk cache settings
// become available we configure our instance to match.
auto assets = assetClient();
if (!assets) {
return false; // not yet possible to initialize the cache
}
if (_cache.cacheDirectory().size()) {
return true; // cache is ready
}
// attempt to initialize the cache
qDebug() << "BaseAssetScriptingInterface::getCacheStatus -- invoking AssetClient::init" << assetClient().data();
QMetaObject::invokeMethod(assetClient().data(), "init");
qDebug() << "BaseAssetScriptingInterface::getCacheStatus querying cache status" << QThread::currentThread();
Promise deferred = makePromise("BaseAssetScriptingInterface--queryCacheStatus");
deferred->then([&](QVariantMap result) {
qDebug() << "//queryCacheStatus" << QThread::currentThread();
auto cacheDirectory = result.value("cacheDirectory").toString();
auto cacheSize = result.value("cacheSize").toLongLong();
auto maximumCacheSize = result.value("maximumCacheSize").toLongLong();
qDebug() << "///queryCacheStatus" << cacheDirectory << cacheSize << maximumCacheSize;
_cache.setCacheDirectory(cacheDirectory);
_cache.setMaximumCacheSize(maximumCacheSize);
});
deferred->fail([&](QString error) {
qDebug() << "//queryCacheStatus ERROR" << QThread::currentThread() << error;
});
assets->cacheInfoRequest(deferred);
return false; // cache is not ready yet
}
QVariantMap BaseAssetScriptingInterface::getCacheStatus() {
//assetClient()->cacheInfoRequest(this, "onCacheInfoResponse");
return {
{ "cacheDirectory", _cache.cacheDirectory() },
{ "cacheSize", _cache.cacheSize() },
{ "maximumCacheSize", _cache.maximumCacheSize() },
};
}
QVariantMap BaseAssetScriptingInterface::queryCacheMeta(const QUrl& url) {
QNetworkCacheMetaData metaData = _cache.metaData(url);
QVariantMap attributes, rawHeaders;
QHashIterator<QNetworkRequest::Attribute, QVariant> i(metaData.attributes());
while (i.hasNext()) {
i.next();
attributes[QString::number(i.key())] = i.value();
}
for (const auto& i : metaData.rawHeaders()) {
rawHeaders[i.first] = i.second;
}
return {
{ "isValid", metaData.isValid() },
{ "url", metaData.url() },
{ "expirationDate", metaData.expirationDate() },
{ "lastModified", metaData.lastModified().toString().isEmpty() ? QDateTime() : metaData.lastModified() },
{ "saveToDisk", metaData.saveToDisk() },
{ "attributes", attributes },
{ "rawHeaders", rawHeaders },
};
}
QVariantMap BaseAssetScriptingInterface::loadFromCache(const QUrl& url) {
qDebug() << "loadFromCache" << url;
QVariantMap result = {
{ "metadata", queryCacheMeta(url) },
{ "data", QByteArray() },
};
// caller is responsible for the deletion of the ioDevice, hence the unique_ptr
if (auto ioDevice = std::unique_ptr<QIODevice>(_cache.data(url))) {
QByteArray data = ioDevice->readAll();
qCDebug(asset_client) << url.toDisplayString() << "loaded from disk cache (" << data.size() << " bytes)";
result["data"] = data;
}
return result;
}
namespace {
// parse RFC 1123 HTTP date format
QDateTime parseHttpDate(const QString& dateString) {
QDateTime dt = QDateTime::fromString(dateString.left(25), "ddd, dd MMM yyyy HH:mm:ss");
dt.setTimeSpec(Qt::UTC);
return dt;
}
}
bool BaseAssetScriptingInterface::saveToCache(const QUrl& url, const QByteArray& data, const QVariantMap& headers) {
QDateTime lastModified = headers.contains("last-modified") ?
parseHttpDate(headers["last-modified"].toString()) :
QDateTime::currentDateTimeUtc();
QDateTime expirationDate = headers.contains("expires") ?
parseHttpDate(headers["expires"].toString()) :
QDateTime(); // never expires
QNetworkCacheMetaData metaData;
metaData.setUrl(url);
metaData.setSaveToDisk(true);
metaData.setLastModified(lastModified);
metaData.setExpirationDate(expirationDate);
if (auto ioDevice = _cache.prepare(metaData)) {
ioDevice->write(data);
_cache.insert(ioDevice);
qCDebug(asset_client) << url.toDisplayString() << "saved to disk cache ("<< data.size()<<" bytes)";
return true;
}
qCWarning(asset_client) << "Could not save" << url.toDisplayString() << "to disk cache.";
return false;
}
Promise BaseAssetScriptingInterface::loadAsset(QString asset, bool decompress, QString responseType) {
auto hash = AssetUtils::extractAssetHash(asset);
auto url = AssetUtils::getATPUrl(hash).toString();
QVariantMap metaData = {
{ "_asset", asset },
{ "_type", "download" },
{ "hash", hash },
{ "url", url },
{ "responseType", responseType },
};
Promise fetched = makePromise("loadAsset::fetched"),
loaded = makePromise("loadAsset::loaded");
downloadBytes(hash)
->mixin(metaData)
->ready([=](QString error, QVariantMap result) {
Q_ASSERT(thread() == QThread::currentThread());
fetched->mixin(result);
if (decompress) {
qDebug() << "loadAsset::decompressBytes...";
decompressBytes(result.value("data").toByteArray())
->mixin(result)
->ready([=](QString error, QVariantMap result) {
fetched->handle(error, result);
});
} else {
fetched->handle(error, result);
}
});
fetched->ready([=](QString error, QVariantMap result) {
qDebug() << "loadAsset::fetched" << error;
if (responseType == "arraybuffer") {
loaded->resolve(NoError, result);
} else {
convertBytes(result.value("data").toByteArray(), responseType)
->mixin(result)
->ready([=](QString error, QVariantMap result) {
loaded->resolve(NoError, result);
});
}
});
return loaded;
}
Promise BaseAssetScriptingInterface::convertBytes(const QByteArray& dataByteArray, const QString& responseType) {
QVariantMap result;
Promise conversion = makePromise(__FUNCTION__);
if (dataByteArray.size() == 0) {
result["response"] = QString();
} else if (responseType == "text") {
result["response"] = QString::fromUtf8(dataByteArray);
} else if (responseType == "json") {
QJsonParseError status;
auto parsed = QJsonDocument::fromJson(dataByteArray, &status);
if (status.error == QJsonParseError::NoError) {
result["response"] = parsed.isArray() ?
QVariant(parsed.array().toVariantList()) :
QVariant(parsed.object().toVariantMap());
} else {
QVariantMap errorResult = {
{ "error", status.error },
{ "offset", status.offset },
};
return conversion->reject("JSON Parse Error: " + status.errorString(), errorResult);
}
} else if (responseType == "arraybuffer") {
result["response"] = dataByteArray;
}
return conversion->resolve(NoError, result);
}
Promise BaseAssetScriptingInterface::decompressBytes(const QByteArray& dataByteArray) {
QByteArray inflated;
auto start = usecTimestampNow();
if (gunzip(dataByteArray, inflated)) {
auto end = usecTimestampNow();
return makePromise(__FUNCTION__)->resolve(NoError, {
{ "_compressedByteLength", dataByteArray.size() },
{ "_compressedContentType", QMimeDatabase().mimeTypeForData(dataByteArray).name() },
{ "_compressMS", (double)(end - start) / 1000.0 },
{ "decompressed", true },
{ "byteLength", inflated.size() },
{ "contentType", QMimeDatabase().mimeTypeForData(inflated).name() },
{ "data", inflated },
});
} else {
return makePromise(__FUNCTION__)->reject("gunzip error", {});
}
}
Promise BaseAssetScriptingInterface::compressBytes(const QByteArray& dataByteArray, int level) {
QByteArray deflated;
auto start = usecTimestampNow();
if (gzip(dataByteArray, deflated, level)) {
auto end = usecTimestampNow();
return makePromise(__FUNCTION__)->resolve(NoError, {
{ "_uncompressedByteLength", dataByteArray.size() },
{ "_uncompressedContentType", QMimeDatabase().mimeTypeForData(dataByteArray).name() },
{ "_compressMS", (double)(end - start) / 1000.0 },
{ "compressed", true },
{ "byteLength", deflated.size() },
{ "contentType", QMimeDatabase().mimeTypeForData(deflated).name() },
{ "data", deflated },
});
} else {
return makePromise(__FUNCTION__)->reject("gzip error", {});
}
}
Promise BaseAssetScriptingInterface::downloadBytes(QString hash) {
auto assetClient = DependencyManager::get<AssetClient>();
QPointer<AssetRequest> assetRequest = assetClient->createRequest(hash);
Promise deferred = makePromise(__FUNCTION__);
QObject::connect(assetRequest, &AssetRequest::finished, assetRequest, [this, deferred](AssetRequest* request) {
qDebug() << "...BaseAssetScriptingInterface::downloadBytes" << request->getErrorString();
// note: we are now on the "Resource Manager" thread
Q_ASSERT(QThread::currentThread() == request->thread());
Q_ASSERT(request->getState() == AssetRequest::Finished);
QString error;
QVariantMap result;
if (request->getError() == AssetRequest::Error::NoError) {
QByteArray data = request->getData();
result = {
{ "url", request->getUrl() },
{ "hash", request->getHash() },
{ "cached", request->loadedFromCache() },
{ "content-type", QMimeDatabase().mimeTypeForData(data).name() },
{ "data", data },
};
} else {
error = request->getError();
result = { { "error", request->getError() } };
}
qDebug() << "//BaseAssetScriptingInterface::downloadBytes" << error << result.keys();
// forward thread-safe copies back to our thread
deferred->handle(error, result);
request->deleteLater();
});
assetRequest->start();
return deferred;
}
Promise BaseAssetScriptingInterface::uploadBytes(const QByteArray& bytes) {
Promise deferred = makePromise(__FUNCTION__);
QPointer<AssetUpload> upload = DependencyManager::get<AssetClient>()->createUpload(bytes);
QObject::connect(upload, &AssetUpload::finished, upload, [=](AssetUpload* upload, const QString& hash) {
Q_ASSERT(QThread::currentThread() == upload->thread());
// note: we are now on the "Resource Manager" thread
QString error;
QVariantMap result;
if (upload->getError() == AssetUpload::NoError) {
result = {
{ "hash", hash },
{ "url", AssetUtils::getATPUrl(hash).toString() },
{ "filename", upload->getFilename() },
};
} else {
error = upload->getErrorString();
result = { { "error", upload->getError() } };
}
// forward thread-safe copies back to our thread
deferred->handle(error, result);
upload->deleteLater();
});
upload->start();
return deferred;
}
Promise BaseAssetScriptingInterface::getAssetInfo(QString asset) {
auto deferred = makePromise(__FUNCTION__);
auto url = AssetUtils::getATPUrl(asset);
auto path = url.path();
auto hash = AssetUtils::extractAssetHash(asset);
if (AssetUtils::isValidHash(hash)) {
// already a valid ATP hash -- nothing to do
deferred->resolve(NoError, {
{ "hash", hash },
{ "path", path },
{ "url", url },
});
} else if (AssetUtils::isValidFilePath(path)) {
auto assetClient = DependencyManager::get<AssetClient>();
QPointer<GetMappingRequest> request = assetClient->createGetMappingRequest(path);
QObject::connect(request, &GetMappingRequest::finished, request, [=]() {
Q_ASSERT(QThread::currentThread() == request->thread());
// note: we are now on the "Resource Manager" thread
QString error;
QVariantMap result;
if (request->getError() == GetMappingRequest::NoError) {
result = {
{ "_hash", hash },
{ "_path", path },
{ "_url", url },
{ "hash", request->getHash() },
{ "wasRedirected", request->wasRedirected() },
{ "path", request->wasRedirected() ? request->getRedirectedPath() : path },
};
} else {
error = request->getErrorString();
result = { { "error", request->getError() } };
}
// forward thread-safe copies back to our thread
deferred->handle(error, result);
request->deleteLater();
});
request->start();
} else {
deferred->reject("invalid ATP file path: " + asset + "("+path+")", {});
}
return deferred;
}
Promise BaseAssetScriptingInterface::symlinkAsset(QString hash, QString path) {
auto deferred = makePromise(__FUNCTION__);
auto assetClient = DependencyManager::get<AssetClient>();
QPointer<SetMappingRequest> setMappingRequest = assetClient->createSetMappingRequest(path, hash);
connect(setMappingRequest, &SetMappingRequest::finished, setMappingRequest, [=](SetMappingRequest* request) {
Q_ASSERT(QThread::currentThread() == request->thread());
// we are now on the "Resource Manager" thread
QString error;
QVariantMap result;
if (request->getError() == SetMappingRequest::NoError) {
result = {
{ "_hash", hash },
{ "_path", path },
{ "hash", request->getHash() },
{ "path", request->getPath() },
{ "url", AssetUtils::getATPUrl(request->getPath()).toString() },
};
} else {
error = request->getErrorString();
result = { { "error", request->getError() } };
}
// forward results back to our thread
deferred->handle(error, result);
request->deleteLater();
});
setMappingRequest->start();
return deferred;
}

View file

@ -0,0 +1,78 @@
//
// BaseAssetScriptingInterface.h
// libraries/networking/src
//
// Copyright 2017 High Fidelity, Inc.
//
// Distributed under the Apache License, Version 2.0.
// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html
//
// BaseAssetScriptingInterface contains the engine-agnostic support code that can be used from
// both QML JS and QScriptEngine JS engine implementations
#ifndef hifi_BaseAssetScriptingInterface_h
#define hifi_BaseAssetScriptingInterface_h
#include <QtCore/QObject>
#include <QtCore/QThread>
#include "AssetClient.h"
#include <shared/MiniPromises.h>
#include "NetworkAccessManager.h"
#include <QtNetwork/QNetworkDiskCache>
/**jsdoc
* @namespace Assets
*/
class BaseAssetScriptingInterface : public QObject {
Q_OBJECT
public:
using Promise = MiniPromise::Promise;
QSharedPointer<AssetClient> assetClient();
BaseAssetScriptingInterface(QObject* parent = nullptr);
public slots:
/**jsdoc
* Get the current status of the disk cache (if available)
* @function Assets.uploadData
* @static
* @return {String} result.cacheDirectory (path to the current disk cache)
* @return {Number} result.cacheSize (used cache size in bytes)
* @return {Number} result.maximumCacheSize (maxmimum cache size in bytes)
*/
QVariantMap getCacheStatus();
/**jsdoc
* Initialize the disk cache (returns true if already initialized)
* @function Assets.initializeCache
* @static
*/
bool initializeCache();
virtual bool isValidPath(QString input) { return AssetUtils::isValidPath(input); }
virtual bool isValidFilePath(QString input) { return AssetUtils::isValidFilePath(input); }
QUrl getATPUrl(QString input) { return AssetUtils::getATPUrl(input); }
QString extractAssetHash(QString input) { return AssetUtils::extractAssetHash(input); }
bool isValidHash(QString input) { return AssetUtils::isValidHash(input); }
QByteArray hashData(const QByteArray& data) { return AssetUtils::hashData(data); }
virtual QVariantMap queryCacheMeta(const QUrl& url);
virtual QVariantMap loadFromCache(const QUrl& url);
virtual bool saveToCache(const QUrl& url, const QByteArray& data, const QVariantMap& metadata = QVariantMap());
protected:
//void onCacheInfoResponse(QString cacheDirectory, qint64 cacheSize, qint64 maximumCacheSize);
QNetworkDiskCache _cache;
const QString NoError{};
//virtual bool jsAssert(bool condition, const QString& error) = 0;
Promise loadAsset(QString asset, bool decompress, QString responseType);
Promise getAssetInfo(QString asset);
Promise downloadBytes(QString hash);
Promise uploadBytes(const QByteArray& bytes);
Promise compressBytes(const QByteArray& bytes, int level = -1);
Promise convertBytes(const QByteArray& dataByteArray, const QString& responseType);
Promise decompressBytes(const QByteArray& bytes);
Promise symlinkAsset(QString hash, QString path);
};
#endif // hifi_BaseAssetScriptingInterface_h

View file

@ -11,43 +11,70 @@
#include "AssetScriptingInterface.h"
#include <QMimeDatabase>
#include <QThread>
#include <QtScript/QScriptEngine>
#include <AssetRequest.h>
#include <AssetUpload.h>
#include <AssetUtils.h>
#include <MappingRequest.h>
#include <NetworkLogging.h>
#include <NodeList.h>
AssetScriptingInterface::AssetScriptingInterface(QScriptEngine* engine) :
_engine(engine)
{
#include <RegisteredMetaTypes.h>
#include <shared/QtHelpers.h>
#include "Gzip.h"
#include "ScriptEngine.h"
//using Promise = MiniPromise::Promise;
AssetScriptingInterface::AssetScriptingInterface(QObject* parent) : BaseAssetScriptingInterface(parent) {
if (auto engine = qobject_cast<QScriptEngine*>(parent)) {
registerMetaTypes(engine);
}
}
#define JS_ASSERT(cond, error) { if (!this->jsAssert(cond, error)) { return; } }
void AssetScriptingInterface::uploadData(QString data, QScriptValue callback) {
auto handler = makeScopedHandlerObject(thisObject(), callback);
QByteArray dataByteArray = data.toUtf8();
auto upload = DependencyManager::get<AssetClient>()->createUpload(dataByteArray);
QObject::connect(upload, &AssetUpload::finished, this, [this, callback](AssetUpload* upload, const QString& hash) mutable {
if (callback.isFunction()) {
QString url = "atp:" + hash;
QScriptValueList args { url, hash };
callback.call(_engine->currentContext()->thisObject(), args);
}
Promise deferred = makePromise(__FUNCTION__)
->ready([this, handler](QString error, QVariantMap result) {
auto url = result.value("url").toString();
auto hash = result.value("hash").toString();
jsCallback(handler, url, hash);
});
connect(upload, &AssetUpload::finished, upload, [this, deferred](AssetUpload* upload, const QString& hash) {
// we are now on the "Resource Manager" thread (and "hash" being a *reference* makes it unsafe to use directly)
Q_ASSERT(QThread::currentThread() == upload->thread());
deferred->resolve(NoError, {
{ "url", "atp:" + hash },
{ "hash", hash },
});
upload->deleteLater();
});
upload->start();
}
void AssetScriptingInterface::setMapping(QString path, QString hash, QScriptValue callback) {
auto setMappingRequest = DependencyManager::get<AssetClient>()->createSetMappingRequest(path, hash);
auto handler = makeScopedHandlerObject(thisObject(), callback);
auto setMappingRequest = assetClient()->createSetMappingRequest(path, hash);
Promise deferred = makePromise(__FUNCTION__)
->ready([=](QString error, QVariantMap result) {
jsCallback(handler, error, result);
});
QObject::connect(setMappingRequest, &SetMappingRequest::finished, this, [this, callback](SetMappingRequest* request) mutable {
if (callback.isFunction()) {
QString error = request->getErrorString();
QScriptValueList args { error };
callback.call(_engine->currentContext()->thisObject(), args);
}
connect(setMappingRequest, &SetMappingRequest::finished, setMappingRequest, [this, deferred](SetMappingRequest* request) {
Q_ASSERT(QThread::currentThread() == request->thread());
// we are now on the "Resource Manager" thread
QString error = request->getErrorString();
// forward a thread-safe values back to our thread
deferred->handle(error, { { "error", request->getError() } });
request->deleteLater();
});
setMappingRequest->start();
@ -55,48 +82,63 @@ void AssetScriptingInterface::setMapping(QString path, QString hash, QScriptValu
void AssetScriptingInterface::downloadData(QString urlString, QScriptValue callback) {
// FIXME: historically this API method failed silently when given a non-atp prefixed
// urlString (or if the AssetRequest failed).
// .. is that by design or could we update without breaking things to provide better feedback to scripts?
if (!urlString.startsWith(ATP_SCHEME)) {
// ... for now at least log a message so user can check logs
qDebug() << "AssetScriptingInterface::downloadData ERROR: url does not start with " << ATP_SCHEME;
return;
}
// Make request to atp
auto path = urlString.right(urlString.length() - ATP_SCHEME.length());
auto parts = path.split(".", QString::SkipEmptyParts);
auto hash = parts.length() > 0 ? parts[0] : "";
QString hash = AssetUtils::extractAssetHash(urlString);
auto handler = makeScopedHandlerObject(thisObject(), callback);
auto assetClient = DependencyManager::get<AssetClient>();
auto assetRequest = assetClient->createRequest(hash);
_pendingRequests << assetRequest;
Promise deferred = makePromise(__FUNCTION__)
->ready([=](QString error, QVariantMap result) {
// FIXME: to remain backwards-compatible the signature here is "callback(data, n/a)"
jsCallback(handler, result.value("data").toString(), { { "errorMessage", error } });
});
connect(assetRequest, &AssetRequest::finished, this, [this, callback](AssetRequest* request) mutable {
connect(assetRequest, &AssetRequest::finished, assetRequest, [this, deferred](AssetRequest* request) {
Q_ASSERT(QThread::currentThread() == request->thread());
// we are now on the "Resource Manager" thread
Q_ASSERT(request->getState() == AssetRequest::Finished);
if (request->getError() == AssetRequest::Error::NoError) {
if (callback.isFunction()) {
QString data = QString::fromUtf8(request->getData());
QScriptValueList args { data };
callback.call(_engine->currentContext()->thisObject(), args);
}
QString data = QString::fromUtf8(request->getData());
// forward a thread-safe values back to our thread
deferred->resolve(NoError, { { "data", data } });
} else {
// FIXME: propagate error to scripts? (requires changing signature or inverting param order above..)
//deferred->resolve(request->getErrorString(), { { "error", requet->getError() } });
qDebug() << "AssetScriptingInterface::downloadData ERROR: " << request->getErrorString();
}
request->deleteLater();
_pendingRequests.remove(request);
});
assetRequest->start();
}
void AssetScriptingInterface::setBakingEnabled(QString path, bool enabled, QScriptValue callback) {
auto handler = makeScopedHandlerObject(thisObject(), callback);
auto setBakingEnabledRequest = DependencyManager::get<AssetClient>()->createSetBakingEnabledRequest({ path }, enabled);
QObject::connect(setBakingEnabledRequest, &SetBakingEnabledRequest::finished, this, [this, callback](SetBakingEnabledRequest* request) mutable {
if (callback.isFunction()) {
QString error = request->getErrorString();
QScriptValueList args{ error };
callback.call(_engine->currentContext()->thisObject(), args);
}
Promise deferred = makePromise(__FUNCTION__)
->ready([=](QString error, QVariantMap result) {
jsCallback(handler, error, result);
});
connect(setBakingEnabledRequest, &SetBakingEnabledRequest::finished, setBakingEnabledRequest, [this, deferred](SetBakingEnabledRequest* request) {
Q_ASSERT(QThread::currentThread() == request->thread());
// we are now on the "Resource Manager" thread
QString error = request->getErrorString();
// forward thread-safe values back to our thread
deferred->handle(error, {});
request->deleteLater();
});
setBakingEnabledRequest->start();
@ -111,3 +153,238 @@ void AssetScriptingInterface::sendFakedHandshake() {
}
#endif
void AssetScriptingInterface::getMapping(QString asset, QScriptValue callback) {
auto path = AssetUtils::getATPUrl(asset).path();
auto handler = makeScopedHandlerObject(thisObject(), callback);
JS_ASSERT(AssetUtils::isValidFilePath(path), "invalid ATP file path: " + asset + "(path:"+path+")");
JS_ASSERT(callback.isFunction(), "expected second parameter to be a callback function");
qDebug() << ">>getMapping//getAssetInfo" << path;
getAssetInfo(path)->ready([this, handler](QString error, QVariantMap result) {
qDebug() << "//getMapping//getAssetInfo" << error << result.keys();
jsCallback(handler, error, result.value("hash").toString());
});
}
/////////////////////////// new APIS ////////////////////////////////////
bool AssetScriptingInterface::jsAssert(bool condition, const QString& error) {
if (condition) {
return true;
}
if (context()) {
context()->throwError(error);
} else {
qDebug() << "WARNING -- jsAssert failed outside of a valid JS context: " + error;
}
return false;
}
void AssetScriptingInterface::jsCallback(const QScriptValue& handler,
const QScriptValue& error, const QScriptValue& result) {
Q_ASSERT(thread() == QThread::currentThread());
auto errorValue = !error.toBool() ? QScriptValue::NullValue : error;
JS_ASSERT(handler.isObject() && handler.property("callback").isFunction(),
QString("jsCallback -- .callback is not a function (%1)")
.arg(handler.property("callback").toVariant().typeName()));
#if 1 || DEGUG_JSCALLBACK
QScriptValue debug = result;
debug.setProperty("toString", handler.engine()->evaluate("1,function() { return JSON.stringify(this, 0, 2); }"));
#endif
::callScopedHandlerObject(handler, errorValue, result);
}
void AssetScriptingInterface::jsCallback(const QScriptValue& handler,
const QScriptValue& error, const QVariantMap& result) {
Q_ASSERT(thread() == QThread::currentThread());
Q_ASSERT(handler.engine());
auto engine = handler.engine();
jsCallback(handler, error, engine->toScriptValue(result));
}
void AssetScriptingInterface::deleteAsset(QScriptValue options, QScriptValue scope, QScriptValue callback) {
jsAssert(false, "TODO: deleteAsset API");
}
void AssetScriptingInterface::getAsset(QScriptValue options, QScriptValue scope, QScriptValue callback) {
JS_ASSERT(options.isObject() || options.isString(), "expected request options Object or URL as first parameter");
auto decompress = options.property("decompress").toBool() || options.property("compressed").toBool();
auto responseType = options.property("responseType").toString().toLower();
auto url = options.property("url").toString();
if (options.isString()) {
url = options.toString();
}
if (responseType.isEmpty() || responseType == "string") {
responseType = "text";
}
auto asset = AssetUtils::getATPUrl(url).path();
auto handler = makeScopedHandlerObject(scope, callback);
JS_ASSERT(handler.property("callback").isFunction(),
QString("Invalid callback function (%1)").arg(handler.property("callback").toVariant().typeName()));
JS_ASSERT(AssetUtils::isValidHash(asset) || AssetUtils::isValidFilePath(asset),
QString("Invalid ATP url '%1'").arg(url));
JS_ASSERT(RESPONSE_TYPES.contains(responseType),
QString("Invalid responseType: '%1' (expected: %2)").arg(responseType).arg(RESPONSE_TYPES.join(" | ")));
Promise resolved = makePromise("resolved"),
loaded = makePromise("loaded");
loaded->ready([=](QString error, QVariantMap result) {
qDebug() << "//loaded" << error;
jsCallback(handler, error, result);
});
resolved->ready([=](QString error, QVariantMap result) {
qDebug() << "//resolved" << result.value("hash");
QString hash = result.value("hash").toString();
if (!error.isEmpty() || !AssetUtils::isValidHash(hash)) {
loaded->reject(error.isEmpty() ? "internal hash error: " + hash : error, result);
} else {
loadAsset(hash, decompress, responseType)
->mixin(result)
->ready([this, loaded, hash](QString error, QVariantMap result) {
qDebug() << "//getAssetInfo/loadAsset" << error << hash;
loaded->resolve(NoError, result);
});
}
});
if (AssetUtils::isValidHash(asset)) {
resolved->resolve(NoError, { { "hash", asset } });
} else {
getAssetInfo(asset)
->ready([this, resolved](QString error, QVariantMap result) {
qDebug() << "//getAssetInfo" << error << result.value("hash") << result.value("path");
resolved->resolve(error, result);
});
}
}
void AssetScriptingInterface::resolveAsset(QScriptValue options, QScriptValue scope, QScriptValue callback) {
const QString& URL{ "url" };
auto url = (options.isString() ? options : options.property(URL)).toString();
auto asset = AssetUtils::getATPUrl(url).path();
auto handler = makeScopedHandlerObject(scope, callback);
JS_ASSERT(AssetUtils::isValidFilePath(asset) || AssetUtils::isValidHash(asset),
"expected options to be an asset URL or request options containing .url property");
JS_ASSERT(handler.property("callback").isFunction(), "invalid callback function");
getAssetInfo(asset)->ready([=](QString error, QVariantMap result) {
qDebug() << "//resolveAsset/getAssetInfo" << error << result.value("hash");
jsCallback(handler, error, result);
});
}
void AssetScriptingInterface::decompressData(QScriptValue options, QScriptValue scope, QScriptValue callback) {
auto data = options.property("data");
QByteArray dataByteArray = qscriptvalue_cast<QByteArray>(data);
auto handler = makeScopedHandlerObject(scope, callback);
auto responseType = options.property("responseType").toString().toLower();
if (responseType.isEmpty()) {
responseType = "text";
}
decompressBytes(dataByteArray)
->ready([=](QString error, QVariantMap result) {
if (responseType == "arraybuffer") {
jsCallback(handler, error, result);
} else {
convertBytes(result.value("data").toByteArray(), responseType)
->mixin(result)
->ready([=](QString error, QVariantMap result) {
jsCallback(handler, error, result);
});
}
});
}
void AssetScriptingInterface::compressData(QScriptValue options, QScriptValue scope, QScriptValue callback) {
auto data = options.property("data");
QByteArray dataByteArray = data.isString() ?
data.toString().toUtf8() :
qscriptvalue_cast<QByteArray>(data);
auto handler = makeScopedHandlerObject(scope, callback);
auto level = options.property("level").toInt32();
if (level < -1 || level > 9) {
level = -1;
}
compressBytes(dataByteArray, level)
->ready([=](QString error, QVariantMap result) {
jsCallback(handler, error, result);
});
}
void AssetScriptingInterface::putAsset(QScriptValue options, QScriptValue scope, QScriptValue callback) {
auto compress = options.property("compress").toBool() ||
options.property("compressed").toBool();
auto handler = makeScopedHandlerObject(scope, callback);
auto data = options.property("data");
auto rawPath = options.property("path").toString();
auto path = AssetUtils::getATPUrl(rawPath).path();
QByteArray dataByteArray = data.isString() ?
data.toString().toUtf8() :
qscriptvalue_cast<QByteArray>(data);
//auto rawByteLength = dataByteArray.size();
JS_ASSERT(path.isEmpty() || AssetUtils::isValidFilePath(path),
QString("expected valid ATP file path '%1' ('%2')").arg(rawPath).arg(path));
JS_ASSERT(handler.property("callback").isFunction(),
"invalid callback function");
JS_ASSERT(dataByteArray.size() > 0,
QString("expected non-zero .data (got %1 / #%2 bytes)")
.arg(data.toVariant().typeName())
.arg(dataByteArray.size()));
// [compressed] => uploaded to server => [mapped to path]
Promise prepared = makePromise("putAsset::prepared"),
uploaded = makePromise("putAsset::uploaded"),
finished = makePromise("putAsset::finished");
if (compress) {
qDebug() << "putAsset::compressBytes...";
compressBytes(dataByteArray, -1)
->finally([=](QString error, QVariantMap result) {
qDebug() << "//putAsset::compressedBytes" << error << result.keys();
prepared->handle(error, result);
});
} else {
prepared->resolve(NoError, {{ "data", dataByteArray }});
}
prepared->ready([=](QString error, QVariantMap result) {
qDebug() << "//putAsset::prepared" << error << result.value("data").toByteArray().size() << result.keys();
uploadBytes(result.value("data").toByteArray())
->mixin(result)
->ready([=](QString error, QVariantMap result) {
qDebug() << "===========//putAsset::prepared/uploadBytes" << error << result.keys();
uploaded->handle(error, result);
});
});
uploaded->ready([=](QString error, QVariantMap result) {
QString hash = result.value("hash").toString();
qDebug() << "//putAsset::uploaded" << error << hash << result.keys();
if (path.isEmpty()) {
finished->handle(error, result);
} else if (!AssetUtils::isValidHash(hash)) {
finished->reject("path mapping requested, but did not receive valid hash", result);
} else {
qDebug() << "symlinkAsset" << hash << path << QThread::currentThread();
symlinkAsset(hash, path)
->mixin(result)
->ready([=](QString error, QVariantMap result) {
finished->handle(error, result);
qDebug() << "//symlinkAsset" << hash << path << result.keys();
});
}
});
finished->ready([=](QString error, QVariantMap result) {
qDebug() << "//putAsset::finished" << error << result.keys();
jsCallback(handler, error, result);
});
}

View file

@ -15,17 +15,21 @@
#define hifi_AssetScriptingInterface_h
#include <QtCore/QObject>
#include <QtCore/QThread>
#include <QtScript/QScriptValue>
#include <QtScript/QScriptable>
#include <AssetClient.h>
#include <NetworkAccessManager.h>
#include <BaseAssetScriptingInterface.h>
#include <QtNetwork/QNetworkDiskCache>
/**jsdoc
* @namespace Assets
*/
class AssetScriptingInterface : public QObject {
class AssetScriptingInterface : public BaseAssetScriptingInterface, QScriptable {
Q_OBJECT
public:
AssetScriptingInterface(QScriptEngine* engine);
AssetScriptingInterface(QObject* parent = nullptr);
/**jsdoc
* Upload content to the connected domain's asset server.
@ -75,16 +79,37 @@ public:
* @param {string} error
*/
Q_INVOKABLE void setMapping(QString path, QString hash, QScriptValue callback);
Q_INVOKABLE void getMapping(QString path, QScriptValue callback);
Q_INVOKABLE void setBakingEnabled(QString path, bool enabled, QScriptValue callback);
#if (PR_BUILD || DEV_BUILD)
Q_INVOKABLE void sendFakedHandshake();
#endif
// Advanced APIs
// getAsset(options, scope[callback(error, result)]) -- fetches an Asset from the Server
// [options.url] an "atp:" style URL, hash, or relative mapped path to fetch
// [options.responseType] the desired reponse type (text | arraybuffer | json)
// [options.decompress] whether to apply gunzip decompression on the stream
// [scope[callback]] continuation-style (error, { responseType, data, byteLength, ... }) callback
const QStringList RESPONSE_TYPES{ "text", "arraybuffer", "json" };
Q_INVOKABLE void getAsset(QScriptValue options, QScriptValue scope, QScriptValue callback = QScriptValue());
// putAsset(options, scope[callback(error, result)]) -- upload a new Aset to the Server
// [options.data] -- (ArrayBuffer|String)
// [options.compress] -- (true|false)
// [options.path=undefined] -- option path mapping to set on the created hash result
// [
Q_INVOKABLE void putAsset(QScriptValue options, QScriptValue scope, QScriptValue callback = QScriptValue());
Q_INVOKABLE void deleteAsset(QScriptValue options, QScriptValue scope, QScriptValue callback = QScriptValue());
Q_INVOKABLE void resolveAsset(QScriptValue options, QScriptValue scope, QScriptValue callback = QScriptValue());
Q_INVOKABLE void decompressData(QScriptValue options, QScriptValue scope, QScriptValue callback = QScriptValue());
Q_INVOKABLE void compressData(QScriptValue options, QScriptValue scope, QScriptValue callback = QScriptValue());
protected:
QSet<AssetRequest*> _pendingRequests;
QScriptEngine* _engine;
void jsCallback(const QScriptValue& handler, const QScriptValue& error, const QVariantMap& result);
void jsCallback(const QScriptValue& handler, const QScriptValue& error, const QScriptValue& result);
bool jsAssert(bool condition, const QString& error);
};
#endif // hifi_AssetScriptingInterface_h

View file

@ -0,0 +1,181 @@
/* eslint-env jasmine */
instrument_testrunner(true);
describe("Assets", function () {
var context = {
definedHash: null,
definedPath: null,
definedContent: null,
};
var NULL_HASH = new Array(64+1).join('0'); // 64 hex
var SAMPLE_FOLDER = '/assetUnitTests';
var SAMPLE_FILE_PATH = SAMPLE_FOLDER + "/test.json";
var SAMPLE_CONTENTS = 'Test Run on ' + JSON.stringify(Date());
var IS_ASSET_HASH_REGEX = /^[a-f0-9]{64}$/i;
var IS_ASSET_URL = /^atp:/;
it('Entities.canWriteAssets', function() {
expect(Entities.canWriteAssets()).toBe(true);
});
it('Assets.extractAssetHash(input)', function() {
// new helper method that provides a catch-all way to get at the sha256 hash
// considering the multiple, different ways ATP hash URLs are found across the
// system and in content.
var POSITIVE_TEST_URLS = [
'atp:HASH',
'atp:HASH.obj',
'atp:HASH.fbx?cache-buster',
'atp:/.baked/HASH/asset.fbx',
'HASH'
];
var NEGATIVE_TEST_URLS = [
'asdf',
'http://domain.tld',
'/path/filename.fbx',
'atp:/path/filename.fbx?#HASH',
''
];
pending();
});
// new APIs
it('Assets.getAsset(options, {callback(error, result)})', function() { pending(); });
it('Assets.putAsset(options, {callback(error, result)})', function() { pending(); });
it('Assets.deleteAsset(options, {callback(error, result)})', function() { pending(); });
it('Assets.resolveAsset(options, {callback(error, result)})', function() { pending(); });
// existing APIs
it('Assets.getATPUrl(input)', function() { pending(); });
it('Assets.isValidPath(input)', function() { pending(); });
it('Assets.isValidFilePath(input)', function() { pending(); });
it('Assets.isValidHash(input)', function() { pending(); });
it('Assets.setBakingEnabled(path, enabled, {callback(error)})', function() { pending(); });
it("Assets.uploadData(data, {callback(url, hash)})", function (done) {
Assets.uploadData(SAMPLE_CONTENTS, function(url, hash) {
expect(url).toMatch(IS_ASSET_URL);
expect(hash).toMatch(IS_ASSET_HASH_REGEX);
context.definedHash = hash; // used in later tests
context.definedContent = SAMPLE_CONTENTS;
print('context.definedHash = ' + context.definedHash);
print('context.definedContent = ' + context.definedContent);
done();
});
});
it("Assets.setMapping(path, hash {callback(error)})", function (done) {
expect(context.definedHash).toMatch(IS_ASSET_HASH_REGEX);
Assets.setMapping(SAMPLE_FILE_PATH, context.definedHash, function(error) {
if (error) error += ' ('+JSON.stringify([SAMPLE_FILE_PATH, context.definedHash])+')';
expect(error).toBe(null);
context.definedPath = SAMPLE_FILE_PATH;
print('context.definedPath = ' + context.definedPath);
done();
});
});
it("Assets.getMapping(path, {callback(error, hash)})", function (done) {
expect(context.definedHash).toMatch(IS_ASSET_HASH_REGEX, 'asfasdf');
expect(context.definedPath).toMatch(/^\//, 'asfasdf');
Assets.getMapping(context.definedPath, function(error, hash) {
if (error) error += ' ('+JSON.stringify([context.definedPath])+')';
expect(error).toBe(null);
expect(hash).toMatch(IS_ASSET_HASH_REGEX);
expect(hash).toEqual(context.definedHash);
done();
});
});
it('Assets.getMapping(nullHash, {callback(data)})', function(done) {
// FIXME: characterization test -- current behavior is that downloadData silently fails
// when given an asset that doesn't exist
Assets.downloadData(NULL_HASH, function(result) {
throw new Error("this callback 'should' not be triggered");
});
setTimeout(function() { done(); }, 2000);
});
it('Assets.downloadData(hash, {callback(data)})', function(done) {
expect(context.definedHash).toMatch(IS_ASSET_HASH_REGEX, 'asfasdf');
Assets.downloadData('atp:' + context.definedHash, function(result) {
expect(result).toEqual(context.definedContent);
done();
});
});
it('Assets.downloadData(nullHash, {callback(data)})', function(done) {
// FIXME: characterization test -- current behavior is that downloadData silently fails
// when given an asset doesn't exist
Assets.downloadData(NULL_HASH, function(result) {
throw new Error("this callback 'should' not be triggered");
});
setTimeout(function() {
done();
}, 2000);
});
describe('exceptions', function() {
describe('Asset.setMapping', function() {
it('-- invalid path', function(done) {
Assets.setMapping('foo', NULL_HASH, function(error/*, result*/) {
expect(error).toEqual('Path is invalid');
/* expect(result).not.toMatch(IS_ASSET_URL); */
done();
});
});
it('-- invalid hash', function(done) {
Assets.setMapping(SAMPLE_FILE_PATH, 'bar', function(error/*, result*/) {
expect(error).toEqual('Hash is invalid');
/* expect(result).not.toMatch(IS_ASSET_URL); */
done();
});
});
});
describe('Asset.getMapping', function() {
it('-- invalid path throws immediate', function() {
expect(function() {
Assets.getMapping('foo', function(error, hash) {
throw new Error("should never make it here...");
});
throw new Error("should never make it here...");
}).toThrowError(/invalid.*path/i);
});
it('-- non-existing path', function(done) {
Assets.getMapping('/foo/bar/'+Date.now(), function(error, hash) {
expect(error).toEqual('Asset not found');
expect(hash).not.toMatch(IS_ASSET_HASH_REGEX);
done();
});
});
});
});
});
// ----------------------------------------------------------------------------
// this stuff allows the unit tests to be loaded indepenently and/or as part of testRunner.js execution
function run() {}
function instrument_testrunner(force) {
if (force || typeof describe === 'undefined') {
// Include testing library
Script.include('../../libraries/jasmine/jasmine.js');
Script.include('../../libraries/jasmine/hifi-boot.js');
run = function() {
if (!/:console/.test(Script.resolvePath(''))) {
// invoke Script.stop (after any async tests complete)
jasmine.getEnv().addReporter({ jasmineDone: Script.stop });
} else {
jasmine.getEnv().addReporter({ jasmineDone: function() { print("JASMINE DONE"); } });
}
// Run the tests
jasmine.getEnv().execute();
};
}
}
run();