nymea/libnymea-core/scriptengine/scriptengine.cpp

507 lines
17 KiB
C++

/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright 2013 - 2020, nymea GmbH
* Contact: contact@nymea.io
*
* This file is part of nymea.
* This project including source code and documentation is protected by
* copyright law, and remains the property of nymea GmbH. All rights, including
* reproduction, publication, editing and translation, are reserved. The use of
* this project is subject to the terms of a license agreement to be concluded
* with nymea GmbH in accordance with the terms of use of nymea GmbH, available
* under https://nymea.io/license
*
* GNU General Public License Usage
* Alternatively, this project may be redistributed and/or modified under the
* terms of the GNU General Public License as published by the Free Software
* Foundation, GNU version 3. This project is distributed in the hope that it
* will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty
* of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* this project. If not, see <https://www.gnu.org/licenses/>.
*
* For any further details and any questions please contact us under
* contact@nymea.io or see our FAQ/Licensing Information on
* https://nymea.io/license/faq
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#include "scriptengine.h"
#include "integrations/thingmanager.h"
#include "scriptaction.h"
#include "scriptevent.h"
#include "scriptstate.h"
#include "scriptalarm.h"
#include "scriptinterfaceaction.h"
#include "scriptinterfacestate.h"
#include "scriptinterfaceevent.h"
#include "scriptthing.h"
#include "scriptthings.h"
#include "types/action.h"
#include "nymeasettings.h"
#include "logging/logengine.h"
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include <QQmlComponent>
#include <QJsonParseError>
#include <QJsonDocument>
#include "loggingcategories.h"
#include <QDir>
NYMEA_LOGGING_CATEGORY(dcScriptEngine, "ScriptEngine")
namespace nymeaserver {
namespace scriptengine {
QList<ScriptEngine*> ScriptEngine::s_engines;
QtMessageHandler ScriptEngine::s_upstreamMessageHandler;
QLoggingCategory::CategoryFilter ScriptEngine::s_oldCategoryFilter = nullptr;
QMutex ScriptEngine::s_loggerMutex;
ScriptEngine::ScriptEngine(ThingManager *thingManager, LogEngine *logEngine, QObject *parent) : QObject(parent),
m_thingManager(thingManager)
{
qmlRegisterType<ScriptEvent>("nymea", 1, 0, "ThingEvent");
qmlRegisterType<ScriptAction>("nymea", 1, 0, "ThingAction");
qmlRegisterType<ScriptState>("nymea", 1, 0, "ThingState");
qmlRegisterType<ScriptInterfaceAction>("nymea", 1, 0, "InterfaceAction");
qmlRegisterType<ScriptInterfaceState>("nymea", 1, 0, "InterfaceState");
qmlRegisterType<ScriptInterfaceEvent>("nymea", 1, 0, "InterfaceEvent");
qmlRegisterType<ScriptAlarm>("nymea", 1, 0, "Alarm");
qmlRegisterType<ScriptThing>("nymea", 1, 0, "Thing");
qmlRegisterType<ScriptThings>("nymea", 1, 0, "Things");
qmlRegisterUncreatableType<Action>("nymea", 1, 0, "Action", "Cannot create Actions. Use ThingAction instead.");
m_logger = logEngine->registerLogSource("scripts", {"id", "event"});
m_engine = new QQmlEngine(this);
m_engine->setProperty("thingManager", reinterpret_cast<quint64>(m_thingManager));
// Don't automatically print script warnings (that is, runtime errors, *not* console.warn() messages)
// to stdout as they'd end up on the "default" logging category.
// We collect them ourselves through the warnings() signal and print them to the dcScriptEngine category.
m_engine->setOutputWarningsToStandardError(false);
connect(m_engine, &QQmlEngine::warnings, this, [this](const QList<QQmlError> &warnings){
foreach (const QQmlError &warning, warnings) {
QMessageLogContext ctx(warning.url().toString().toUtf8(), warning.line(), "", "ScriptEngine");
// Send to script logs
onScriptMessage(
#if QT_VERSION >= QT_VERSION_CHECK(5,9,0)
warning.messageType(),
#else
QtMsgType::QtWarningMsg,
#endif
ctx, warning.description());
// and to logging system
qCWarning(dcScriptEngine()) << warning.toString();
}
});
// console.log()/warn() messages instead are printed to the "qml" category. We install our own
// filter to *always* get them, regardless of the configured logging categories
if (!s_oldCategoryFilter) {
s_oldCategoryFilter = QLoggingCategory::installFilter(&logCategoryFilter);
}
// and our own handler to redirect them to the ScriptEngine category
if (s_engines.isEmpty()) {
s_upstreamMessageHandler = qInstallMessageHandler(&logMessageHandler);
}
s_engines.append(this);
QDir dir;
if (!dir.exists(NymeaSettings::storagePath() + "/scripts/")) {
dir.mkpath(NymeaSettings::storagePath() + "/scripts/");
}
loadScripts();
}
ScriptEngine::~ScriptEngine()
{
foreach (Script *script, m_scripts) {
unloadScript(script);
delete script;
}
s_engines.removeAll(this);
if (s_engines.isEmpty()) {
qInstallMessageHandler(s_upstreamMessageHandler);
}
}
Scripts ScriptEngine::scripts()
{
Scripts ret;
foreach (Script *script, m_scripts) {
ret.append(*script);
}
return ret;
}
ScriptEngine::GetScriptReply ScriptEngine::scriptContent(const QUuid &id)
{
GetScriptReply reply;
if (!m_scripts.contains(id)) {
reply.scriptError = ScriptErrorScriptNotFound;
return reply;
}
QFile scriptFile(baseName(id) + ".qml");
if (!scriptFile.open(QFile::ReadOnly)) {
reply.scriptError = ScriptErrorHardwareFailure;
return reply;
}
reply.content = scriptFile.readAll();
reply.scriptError = ScriptErrorNoError;
scriptFile.close();
return reply;
}
ScriptEngine::AddScriptReply ScriptEngine::addScript(const QString &name, const QByteArray &content)
{
QUuid id = QUuid::createUuid();
QString fileName = baseName(id) + ".qml";
QString jsonFileName = baseName(id) + ".json";
AddScriptReply reply;
QFile jsonFile(jsonFileName);
if (!jsonFile.open(QFile::ReadWrite)) {
qCWarning(dcScriptEngine()) << "Error opening script metadata" << jsonFileName;
reply.scriptError = ScriptErrorHardwareFailure;
return reply;
}
QVariantMap metadata;
metadata.insert("name", name);
jsonFile.write(QJsonDocument::fromVariant(metadata).toJson());
jsonFile.close();
QFile scriptFile(fileName);
if (!scriptFile.open(QFile::WriteOnly)) {
qCWarning(dcScriptEngine()) << "Error opening script file:" << fileName;
reply.scriptError = ScriptErrorHardwareFailure;
return reply;
}
qint64 len = scriptFile.write(content);
if (len != content.length()) {
qCWarning(dcScriptEngine()) << "Error writing script content";
reply.scriptError = ScriptErrorHardwareFailure;
return reply;
}
scriptFile.close();
Script *script = new Script();
script->setId(id);
script->setName(name);
bool loaded = loadScript(script);
if (!loaded) {
reply.scriptError = ScriptErrorInvalidScript;
reply.errors = script->errors;
delete script;
QFile::remove(jsonFileName);
QFile::remove(fileName);
return reply;
}
m_scripts.insert(script->id(), script);
reply.scriptError = ScriptErrorNoError;
reply.script = *m_scripts.value(id);
emit scriptAdded(reply.script);
return reply;
}
ScriptEngine::ScriptError ScriptEngine::renameScript(const QUuid &id, const QString &name)
{
if (!m_scripts.contains(id)) {
qCWarning(dcScriptEngine()) << "No script with id" << id;
return ScriptErrorScriptNotFound;
}
QString jsonFileName = baseName(id) + ".json";
QFile jsonFile(jsonFileName);
if (!jsonFile.open(QFile::ReadWrite)) {
qCWarning(dcJsonRpc()) << "Erorr opening script json file" << jsonFileName;
return ScriptErrorHardwareFailure;
}
QJsonParseError error;
QJsonDocument jsonDocument = QJsonDocument::fromJson(jsonFile.readAll(), &error);
QVariantMap jsonData = jsonDocument.toVariant().toMap();
if (error.error != QJsonParseError::NoError) {
qCWarning(dcScriptEngine()) << "Error parsing json file. Recreating it...";
// This is non-critical as we could open it. We can recreate it now.
}
jsonData["name"] = name;
QByteArray jsonString = QJsonDocument::fromVariant(jsonData).toJson();
if (!jsonFile.resize(0) || jsonFile.write(jsonString) != jsonString.length()) {
qCWarning(dcScriptEngine()) << "Error writing json metadata" << jsonFileName;
return ScriptErrorHardwareFailure;
}
jsonFile.close();
m_scripts[id]->setName(name);
qCDebug(dcScriptEngine()) << "Script" << id << "renamed to" << name;
emit scriptRenamed(*m_scripts.value(id));
return ScriptErrorNoError;
}
ScriptEngine::EditScriptReply ScriptEngine::editScript(const QUuid &id, const QByteArray &content)
{
EditScriptReply reply;
if (!m_scripts.contains(id)) {
qCWarning(dcScriptEngine()) << "No script with id" << id;
reply.scriptError = ScriptErrorScriptNotFound;
return reply;
}
Script *script = m_scripts.value(id);
unloadScript(script);
// Deleted compiled qml file to make sure we're reloading the new one
QString compiledScriptFileName = baseName(id) + ".qmlc";
QFile::remove(compiledScriptFileName);
QString scriptFileName = baseName(id) + ".qml";
QFile scriptFile(scriptFileName);
if (!scriptFile.open(QFile::ReadWrite)) {
qCWarning(dcScriptEngine()) << "Error opening script" << id;
reply.scriptError = ScriptErrorHardwareFailure;
return reply;
}
QByteArray oldContent = scriptFile.readAll();
scriptFile.close();
scriptFile.open(QFile::WriteOnly | QFile::Truncate);
qint64 bytesWritten = scriptFile.write(content);
scriptFile.flush();
scriptFile.close();
if (bytesWritten != content.length()) {
qCWarning(dcScriptEngine()) << "Error writing script content";
reply.scriptError = ScriptErrorHardwareFailure;
return reply;
}
bool loaded = loadScript(script);
if (!loaded) {
qCDebug(dcScriptEngine()) << "Restoring old content";
reply.scriptError = ScriptErrorInvalidScript;
reply.errors = script->errors;
// Restore old content
scriptFile.open(QFile::WriteOnly | QFile::Truncate);
scriptFile.write(oldContent);
scriptFile.flush();
scriptFile.close();
loadScript(script);
return reply;
}
qCDebug(dcScriptEngine()) << "Script updated" << script->name();
reply.scriptError = ScriptErrorNoError;
emit scriptChanged(*script);
return reply;
}
ScriptEngine::ScriptError ScriptEngine::removeScript(const QUuid &id)
{
Script *script = m_scripts.take(id);
if (!script) {
return ScriptErrorScriptNotFound;
}
unloadScript(script);
QString jsonFileName = baseName(id) + ".json";
QString scriptFileName = baseName(id) + ".qml";
QString compiledScriptFileName = baseName(id) + ".qmlc";
QFile::remove(scriptFileName);
QFile::remove(jsonFileName);
QFile::remove(compiledScriptFileName);
emit scriptRemoved(script->id());
delete script;
return ScriptErrorNoError;
}
void ScriptEngine::loadScripts()
{
QDir dir(NymeaSettings::storagePath() + "/scripts/");
foreach (const QString &entry, dir.entryList({"*.json"})) {
qCDebug(dcScriptEngine()) << "Have script:" << entry;
QFileInfo jsonFileInfo(NymeaSettings::storagePath() + "/scripts/" + entry);
QString jsonFileName = jsonFileInfo.absoluteFilePath();
QString scriptFileName = jsonFileInfo.absolutePath() + "/" + jsonFileInfo.baseName() + ".qml";
if (!QFile::exists(scriptFileName)) {
qCWarning(dcScriptEngine()) << "Missing script" << scriptFileName;
continue;
}
QFile jsonFile(jsonFileName);
if (!jsonFile.open(QFile::ReadOnly)) {
qCWarning(dcScriptEngine()) << "Failed to open script metadata" << jsonFileName;
continue;
}
QJsonParseError error;
QJsonDocument jsonDoc = QJsonDocument::fromJson(jsonFile.readAll(), &error);
jsonFile.close();
if (error.error != QJsonParseError::NoError) {
qCWarning(dcScriptEngine()) << "Error parsing script metadata" << jsonFileName;
continue;
}
Script *script = new Script();
script->setId(jsonFileInfo.baseName());
script->setName(jsonDoc.toVariant().toMap().value("name").toString());
bool loaded = loadScript(script);
if (!loaded) {
qCWarning(dcScriptEngine()) << "Script failed to load:";
delete script;
continue;
}
m_scripts.insert(script->id(), script);
qCDebug(dcScriptEngine()) << "Script loaded" << scriptFileName;
}
}
bool ScriptEngine::loadScript(Script *script)
{
qCDebug(dcScriptEngine()) << "Loading script" << script->name();
QString fileName = baseName(script->id()) + ".qml";
QString jsonFileName = baseName(script->id()) + ".json";
QFile jsonFile(jsonFileName);
if (!jsonFile.open(QFile::ReadOnly)) {
qCWarning(dcScriptEngine()) << "Failed to open script metadata";
return false;
}
QJsonParseError error;
QJsonDocument jsonDoc = QJsonDocument::fromJson(jsonFile.readAll(), &error);
jsonFile.close();
if (error.error != QJsonParseError::NoError) {
qCWarning(dcScriptEngine()) << "Failed to parse script metadata";
return false;
}
QString name = jsonDoc.toVariant().toMap().value("name").toString();
script->errors.clear();
script->component = new QQmlComponent(m_engine, QUrl::fromLocalFile(fileName), this);
script->context = new QQmlContext(m_engine, this);
script->context->setContextProperty("logger", QVariant::fromValue(m_logger));
script->context->setContextProperty("scriptId", script->id().toString());
script->object = script->component->create(script->context);
if (!script->object) {
qCWarning(dcScriptEngine()) << "Script failed to load:";
foreach (const QQmlError &error, script->component->errors()) {
qCWarning(dcScriptEngine()) << error.toString();
script->errors.append(QString("%1:%2: %3").arg(error.line()).arg(error.column()).arg(error.description()));
}
delete script->context;
delete script->component;
m_engine->clearComponentCache();
return false;
}
return true;
}
void ScriptEngine::unloadScript(Script *script)
{
if (!script->object || !script->component || !script->context) {
qCWarning(dcScriptEngine()) << "Script seems not to be loaded. Cannot unload.";
return;
}
delete script->object;
script->object = nullptr;
delete script->component;
script->component = nullptr;
delete script->context;
script->context = nullptr;
m_engine->clearComponentCache();
qCDebug(dcScriptEngine()) << "Unloading script" << script->name();
}
QString ScriptEngine::baseName(const QUuid &id)
{
QString path = NymeaSettings::storagePath() + "/scripts/";
QString basename = id.toString().remove(QRegExp("[{}]"));
return path + basename;
}
void ScriptEngine::onScriptMessage(QtMsgType type, const QMessageLogContext &context, const QString &message)
{
QFileInfo fi(context.file);
QUuid scriptId = fi.baseName();
if (!m_scripts.contains(scriptId)) {
return;
}
emit scriptConsoleMessage(scriptId, type == QtDebugMsg ? ScriptMessageTypeLog : ScriptMessageTypeWarning, QString::number(context.line) + ": " + message);
}
void ScriptEngine::logMessageHandler(QtMsgType type, const QMessageLogContext &context, const QString &message)
{
if (strcmp(context.category, "qml") != 0 && strcmp(context.category, "ScriptRuntime") != 0) {
s_upstreamMessageHandler(type, context, message);
return;
}
QMutexLocker locker(&s_loggerMutex);
// Copy the message to the script engine
foreach (ScriptEngine *engine, s_engines) {
engine->onScriptMessage(type, context, message);
}
if (!s_oldCategoryFilter) {
return;
}
// Redirect qml messages to the ScriptEngine handler
QMessageLogContext newContext(context.file, context.line, context.function, "ScriptEngine");
QLoggingCategory *category = new QLoggingCategory("ScriptEngine", type);
s_oldCategoryFilter(category);
if (category->isEnabled(type)) {
QFileInfo fi(context.file);
s_upstreamMessageHandler(type, newContext, fi.fileName() + ":" + QString::number(context.line) + ": " + message);
}
delete category;
}
void ScriptEngine::logCategoryFilter(QLoggingCategory *category)
{
// always enable qml logs, regardless what the filters are
if (qstrcmp(category->categoryName(), "qml") == 0) {
category->setEnabled(QtDebugMsg, true);
category->setEnabled(QtWarningMsg, true);
} else if (s_oldCategoryFilter) {
s_oldCategoryFilter(category);
}
}
}
}