diff --git a/cli/CMakeLists.txt b/cli/CMakeLists.txt index 47f5af2..818e72e 100644 --- a/cli/CMakeLists.txt +++ b/cli/CMakeLists.txt @@ -5,6 +5,7 @@ set(META_PROJECT_NAME syncthingctl) set(META_APP_NAME "Syncthing control") set(META_APP_DESCRIPTION "Command line app to control Syncthing") set(META_PROJECT_TYPE application) +set(META_JS_SRC_DIR .) # add project files set(HEADER_FILES @@ -32,6 +33,11 @@ use_syncthingconnector() # include modules to apply configuration include(BasicConfig) +include(JsProviderConfig) +if(JS_PROVIDER) + list(APPEND HEADER_FILES jsconsole.h) + list(APPEND SRC_FILES jsconsole.cpp) +endif() include(QtConfig) include(WindowsResources) include(AppTarget) diff --git a/cli/application.cpp b/cli/application.cpp index cb78049..1f6357b 100644 --- a/cli/application.cpp +++ b/cli/application.cpp @@ -1,5 +1,8 @@ #include "./application.h" #include "./helper.h" +#include "./jsconsole.h" +#include "./jsdefs.h" +#include "./jsincludes.h" #include "../connector/syncthingconfig.h" #include "../connector/utils.h" @@ -8,6 +11,8 @@ #define SYNCTHINGTESTHELPER_FOR_CLI #include "../testhelper/helper.h" +#include "resources/config.h" + #include #include #include @@ -244,6 +249,16 @@ bool Application::waitForConfig(int timeout) signalInfo(&m_connection, &SyncthingConnection::newDirs), signalInfo(&m_connection, &SyncthingConnection::newDevices)); } +bool Application::waitForConfigAndStatus(int timeout) +{ + using namespace TestUtilities; + m_connection.applySettings(m_settings); + return waitForSignalsOrFail(bind(&SyncthingConnection::requestConfigAndStatus, ref(m_connection)), timeout, + signalInfo(&m_connection, &SyncthingConnection::error), signalInfo(&m_connection, &SyncthingConnection::newConfig), + signalInfo(&m_connection, &SyncthingConnection::newDirs), signalInfo(&m_connection, &SyncthingConnection::newDevices), + signalInfo(&m_connection, &SyncthingConnection::myIdChanged)); +} + void Application::handleStatusChanged(SyncthingStatus newStatus) { Q_UNUSED(newStatus) @@ -586,7 +601,6 @@ void Application::printConfig(const ArgumentOccurrence &) return; } cerr << Phrases::Override; - cout << QJsonDocument(m_connection.rawConfig()).toJson(QJsonDocument::Indented).data() << flush; } @@ -595,13 +609,47 @@ void Application::editConfig(const ArgumentOccurrence &) // disable main event loop since this method is invoked directly as argument callback and we're doing all required async operations during the waitForConfig() call already m_requiresMainEventLoop = false; + // wait until config is available + if (!(m_args.script.isPresent() ? waitForConfigAndStatus() : waitForConfig())) { + return; + } + cerr << Phrases::Override; + + const auto newConfig(m_args.script.isPresent() ? editConfigViaScript() : editConfigViaEditor()); + if (newConfig.isEmpty()) { + // just return here; an error message should have already been printed by editConfigVia*() + return; + } + + // handle "dry-run" case + if (m_args.dryRun.isPresent()) { + cout << newConfig.data(); + if (!newConfig.endsWith('\n')) { + cout << '\n'; + } + cout << flush; + return; + } + + // post new config + using namespace TestUtilities; + cerr << Phrases::Info << "Posting new configuration ..." << TextAttribute::Reset << flush; + if (!waitForSignalsOrFail(bind(&SyncthingConnection::postConfigFromByteArray, ref(m_connection), ref(newConfig)), 0, + signalInfo(&m_connection, &SyncthingConnection::error), signalInfo(&m_connection, &SyncthingConnection::newConfigTriggered))) { + return; + } + cerr << Phrases::Override << Phrases::Info << "Configuration posted successfully" << Phrases::EndFlush; +} + +QByteArray Application::editConfigViaEditor() const +{ // read editor command and options const auto *const editorArgValue(m_args.editor.firstValue()); const auto editorCommand(editorArgValue ? QString::fromLocal8Bit(editorArgValue) : QString()); if (editorCommand.isEmpty()) { cerr << Phrases::Error << "No editor command specified. It must be either passed via --editor argument or EDITOR environment variable." << Phrases::EndFlush; - return; + return QByteArray(); } QStringList editorOptions; if (m_args.editor.isPresent()) { @@ -614,17 +662,11 @@ void Application::editConfig(const ArgumentOccurrence &) } } - // wait until config is available - if (!waitForConfig()) { - return; - } - cerr << Phrases::Override; - // write config to temporary file QTemporaryFile tempFile(QStringLiteral("syncthing-config-XXXXXX.json")); if (!tempFile.open() || !tempFile.write(QJsonDocument(m_connection.rawConfig()).toJson(QJsonDocument::Indented))) { cerr << Phrases::Error << "Unable to write the configuration to a temporary file." << Phrases::EndFlush; - return; + return QByteArray(); } editorOptions << tempFile.fileName(); tempFile.close(); @@ -650,19 +692,19 @@ void Application::editConfig(const ArgumentOccurrence &) } } cerr << endl; - return; + return QByteArray(); } // read (altered) configuration again QFile tempFile2(editorOptions.back()); if (!tempFile2.open(QIODevice::ReadOnly)) { cerr << Phrases::Error << "Unable to open temporary file containing the configuration again." << Phrases::EndFlush; - return; + return QByteArray(); } const auto newConfig(tempFile2.readAll()); if (newConfig.isEmpty()) { cerr << Phrases::Error << "Unable to read any bytes from temporary file containing the configuration." << Phrases::EndFlush; - return; + return QByteArray(); } // convert the config to JSON again (could send it to Syncthing as it is, but this allows us to check whether the JSON is valid) @@ -671,42 +713,113 @@ void Application::editConfig(const ArgumentOccurrence &) if (error.error != QJsonParseError::NoError) { cerr << Phrases::Error << "Unable to parse new configuration" << Phrases::End << "reason: " << error.errorString().toLocal8Bit().data() << " at character " << error.offset << endl; - return; + return QByteArray(); } // perform at least some checks before sending the configuration const auto configObj(configDoc.object()); if (configObj.isEmpty()) { cerr << Phrases::Error << "New config object seems empty." << Phrases::EndFlush; - return; + return QByteArray(); } - for (const auto &arrayName : {QStringLiteral("devices"), QStringLiteral("folders")}) { + for (const auto &arrayName : { QStringLiteral("devices"), QStringLiteral("folders") }) { if (!configObj.value(arrayName).isArray()) { cerr << Phrases::Error << "Array \"" << arrayName.toLocal8Bit().data() << "\" is not present." << Phrases::EndFlush; - return; + return QByteArray(); } } - for (const auto &objectName : {QStringLiteral("options"), QStringLiteral("gui")}) { + for (const auto &objectName : { QStringLiteral("options"), QStringLiteral("gui") }) { if (!configObj.value(objectName).isObject()) { cerr << Phrases::Error << "Object \"" << objectName.toLocal8Bit().data() << "\" is not present." << Phrases::EndFlush; - return; + return QByteArray(); + } + } + return newConfig; +} + +QByteArray Application::editConfigViaScript() const +{ +#if defined(SYNCTHINGCTL_USE_SCRIPT) || defined(SYNCTHINGCTL_USE_JSENGINE) + // read script file + QFile scriptFile(QString::fromLocal8Bit(m_args.script.firstValue())); + if (!scriptFile.open(QFile::ReadOnly)) { + cerr << Phrases::Error << "Unable to open specified script file \"" << m_args.script.firstValue() << "\"." << Phrases::EndFlush; + return QByteArray(); + } + const auto script(scriptFile.readAll()); + if (script.isEmpty()) { + cerr << Phrases::Error << "Unable to read any bytes from specified script file \"" << m_args.script.firstValue() << "\"." + << Phrases::EndFlush; + return QByteArray(); + } + + // define function to print error + const auto printError([](const auto &object) { + cerr << object.toString().toLocal8Bit().data() << "\nin line " << SYNCTHINGCTL_JS_INT(object.property(QStringLiteral("lineNumber"))) << endl; + }); + + // evaluate config via JSON.parse() + SYNCTHINGCTL_JS_ENGINE engine; + auto globalObject(engine.globalObject()); + const auto configString(QJsonDocument(m_connection.rawConfig()).toJson(QJsonDocument::Indented)); + globalObject.setProperty(QStringLiteral("configStr"), SYNCTHINGCTL_JS_VALUE(QString::fromUtf8(configString)) SYNCTHINGCTL_JS_READONLY); + const auto configObj(engine.evaluate(QStringLiteral("JSON.parse(configStr)"))); + if (configObj.isError()) { + cerr << Phrases::Error << "Unable to evaluate the current Syncthing configuration." << Phrases::End; + printError(configObj); + cerr << "Syncthing configuration: " << configString.data() << flush; + return QByteArray(); + } + globalObject.setProperty(QStringLiteral("config"), configObj SYNCTHINGCTL_JS_UNDELETABLE); + + // provide additional values + globalObject.setProperty(QStringLiteral("ownID"), m_connection.myId() SYNCTHINGCTL_JS_UNDELETABLE); + globalObject.setProperty(QStringLiteral("url"), m_connection.syncthingUrl() SYNCTHINGCTL_JS_UNDELETABLE); + + // provide console.log() which is not available in QJSEngine and QScriptEngine by default (note that print() is only available when using Qt Script) + JSConsole console; + engine.globalObject().setProperty("console", engine.newQObject(&console)); + + // evaluate the user provided script + const auto res(engine.evaluate(QString::fromUtf8(script), scriptFile.fileName())); + if (res.isError()) { + cerr << Phrases::Error << "Unable to evaluate the specified script file \"" << m_args.script.firstValue() << "\"." << Phrases::End; + printError(res); + return QByteArray(); + } + + // validate the altered configuration + const auto newConfigObj(globalObject.property(QStringLiteral("config"))); + if (!newConfigObj.isObject()) { + cerr << Phrases::Error << "New config object seems empty." << Phrases::EndFlush; + return QByteArray(); + } + for (const auto &arrayName : { QStringLiteral("devices"), QStringLiteral("folders") }) { + if (!newConfigObj.property(arrayName).isArray()) { + cerr << Phrases::Error << "Array \"" << arrayName.toLocal8Bit().data() << "\" is not present." << Phrases::EndFlush; + return QByteArray(); + } + } + for (const auto &objectName : { QStringLiteral("options"), QStringLiteral("gui") }) { + if (!newConfigObj.property(objectName).isObject()) { + cerr << Phrases::Error << "Object \"" << objectName.toLocal8Bit().data() << "\" is not present." << Phrases::EndFlush; + return QByteArray(); } } - // handle "dry-run" case - if (m_args.dryRun.isPresent()) { - cout << newConfig.data() << flush; - return; + // serilaize the altered configuration via JSON.stringify() + const auto newConfigJson(engine.evaluate(QStringLiteral("JSON.stringify(config, null, 4)"))); + if (!newConfigJson.isString()) { + cerr << Phrases::Error << "Unable to convert the config object to JSON via JSON.stringify()." << Phrases::End; + cerr << configObj.toString().toLocal8Bit().data() << endl; + return QByteArray(); } + return newConfigJson.toString().toUtf8(); - // post new config - using namespace TestUtilities; - cerr << Phrases::Info << "Posting new configuration ..." << TextAttribute::Reset << flush; - if (!waitForSignalsOrFail(bind(&SyncthingConnection::postConfig, ref(m_connection), ref(configObj)), 0, - signalInfo(&m_connection, &SyncthingConnection::error), signalInfo(&m_connection, &SyncthingConnection::newConfigTriggered))) { - return; - } - cerr << Phrases::Override << Phrases::Info << "Configuration posted successfully" << Phrases::EndFlush; +#else + cerr << Phrases::Error << PROJECT_NAME " has not been built with JavaScript support." << Phrases::EndFlush; + return QByteArray(); +#endif } void Application::waitForIdle(const ArgumentOccurrence &) diff --git a/cli/application.h b/cli/application.h index ab65524..49c3c13 100644 --- a/cli/application.h +++ b/cli/application.h @@ -55,6 +55,7 @@ private: int loadConfig(); bool waitForConnected(int timeout = 2000); bool waitForConfig(int timeout = 2000); + bool waitForConfigAndStatus(int timeout = 2000); void requestLog(const ArgumentOccurrence &); void requestShutdown(const ArgumentOccurrence &); void requestRestart(const ArgumentOccurrence &); @@ -67,6 +68,8 @@ private: static void printLog(const std::vector &logEntries); void printConfig(const ArgumentOccurrence &); void editConfig(const ArgumentOccurrence &); + QByteArray editConfigViaEditor() const; + QByteArray editConfigViaScript() const; void waitForIdle(const ArgumentOccurrence &); bool checkWhetherIdle() const; void checkPwdOperationPresent(const ArgumentOccurrence &occurrence); diff --git a/cli/args.cpp b/cli/args.cpp index f1a98e7..1fcada7 100644 --- a/cli/args.cpp +++ b/cli/args.cpp @@ -22,6 +22,7 @@ Args::Args() , rescanPwd("rescan", 'r', "rescans the current working directory") , pausePwd("pause", 'p', "pauses the current working directory") , resumePwd("resume", '\0', "resumes the current working directory") + , script("script", '\0', "runs the specified UTF-8 encoded ECMAScript on the configuration rather than opening an editor", { "path" }) , dryRun("dry-run", '\0', "writes the altered configuration to stdout instead of posting it to Syncthing") , dir("dir", 'd', "specifies a directory by ID", { "ID" }) , dev("dev", '\0', "specifies a device by ID or name", { "ID/name" }) @@ -48,7 +49,7 @@ Args::Args() waitForIdle.setExample(PROJECT_NAME " wait-for-idle --timeout 1800000 --at-least 5000 && systemctl poweroff\n" PROJECT_NAME " wait-for-idle --dir dir1 --dir dir2 --dev dev1 --dev dev2 --at-least 5000"); pwd.setSubArguments({ &statusPwd, &rescanPwd, &pausePwd, &resumePwd }); - edit.setSubArguments({ &editor, &dryRun }); + edit.setSubArguments({ &editor, &script, &dryRun }); rescan.setValueNames({ "dir ID" }); rescan.setRequiredValueCount(Argument::varValueCount); diff --git a/cli/args.h b/cli/args.h index 598785e..07f3a89 100644 --- a/cli/args.h +++ b/cli/args.h @@ -14,7 +14,7 @@ struct Args { NoColorArgument noColor; OperationArgument status, log, stop, restart, rescan, rescanAll, pause, resume, waitForIdle, pwd, cat, edit; OperationArgument statusPwd, rescanPwd, pausePwd, resumePwd; - ConfigValueArgument dryRun; + ConfigValueArgument script, dryRun; ConfigValueArgument dir, dev, allDirs, allDevs; ConfigValueArgument atLeast, timeout; ConfigValueArgument editor; diff --git a/cli/jsconsole.cpp b/cli/jsconsole.cpp new file mode 100644 index 0000000..a05ae44 --- /dev/null +++ b/cli/jsconsole.cpp @@ -0,0 +1,14 @@ +#include "./jsconsole.h" + +#include + +using namespace std; + +JSConsole::JSConsole(QObject *parent) : QObject(parent) +{ +} + +void JSConsole::log(const QString &msg) const +{ + cerr << "script: "<< msg.toLocal8Bit().data() << endl; +} diff --git a/cli/jsconsole.h b/cli/jsconsole.h new file mode 100644 index 0000000..359fda8 --- /dev/null +++ b/cli/jsconsole.h @@ -0,0 +1,16 @@ +#ifndef CLI_JS_CONSOLE_H +#define CLI_JS_CONSOLE_H + +#include + +class JSConsole : public QObject +{ + Q_OBJECT +public: + explicit JSConsole(QObject *parent = nullptr); + +public slots: + void log(const QString &msg) const; +}; + +#endif // CLI_JS_CONSOLE_H diff --git a/cli/jsdefs.h b/cli/jsdefs.h new file mode 100644 index 0000000..ed37efb --- /dev/null +++ b/cli/jsdefs.h @@ -0,0 +1,36 @@ +// Created via CMake from template jsdefs.h.in +// WARNING! Any changes to this file will be overwritten by the next CMake run! + +#ifndef SYNCTHINGCTL_JAVA_SCRIPT_DEFINES +#define SYNCTHINGCTL_JAVA_SCRIPT_DEFINES + +#include + +#if defined(SYNCTHINGCTL_USE_JSENGINE) +# define SYNCTHINGCTL_JS_ENGINE QJSEngine +# define SYNCTHINGCTL_JS_VALUE QJSValue +# define SYNCTHINGCTL_JS_READONLY +# define SYNCTHINGCTL_JS_UNDELETABLE +# define SYNCTHINGCTL_JS_QOBJECT(engine, obj) engine.newQObject(obj) +# define SYNCTHINGCTL_JS_INT(value) value.toInt() +# define SYNCTHINGCTL_JS_IS_VALID_PROG(program) (!program.isError() && program.isCallable()) +#elif defined(SYNCTHINGCTL_USE_SCRIPT) +# define SYNCTHINGCTL_JS_ENGINE QScriptEngine +# define SYNCTHINGCTL_JS_VALUE QScriptValue +# define SYNCTHINGCTL_JS_READONLY ,QScriptValue::ReadOnly +# define SYNCTHINGCTL_JS_UNDELETABLE ,QScriptValue::Undeletable +# define SYNCTHINGCTL_JS_QOBJECT(engine, obj) engine.newQObject(obj, QScriptEngine::ScriptOwnership) +# define SYNCTHINGCTL_JS_INT(value) value.toInt32() +# define SYNCTHINGCTL_JS_IS_VALID_PROG(program) (!program.isError() && program.isFunction()) +#elif !defined(SYNCTHINGCTL_NO_JSENGINE) +# error "No definition for JavaScript provider present." +#endif + +#ifdef SYNCTHINGCTL_JS_ENGINE +QT_FORWARD_DECLARE_CLASS(SYNCTHINGCTL_JS_ENGINE) +#endif +#ifdef SYNCTHINGCTL_JS_VALUE +QT_FORWARD_DECLARE_CLASS(SYNCTHINGCTL_JS_VALUE) +#endif + +#endif // SYNCTHINGCTL_JAVA_SCRIPT_DEFINES diff --git a/cli/jsincludes.h b/cli/jsincludes.h new file mode 100644 index 0000000..554aead --- /dev/null +++ b/cli/jsincludes.h @@ -0,0 +1,19 @@ +// Created via CMake from template jsincludes.h.in +// WARNING! Any changes to this file will be overwritten by the next CMake run! + +#ifndef SYNCTHINGCTL_JAVA_SCRIPT_INCLUDES +#define SYNCTHINGCTL_JAVA_SCRIPT_INCLUDES + +#include + +#if defined(SYNCTHINGCTL_USE_JSENGINE) +# include +# include +#elif defined(SYNCTHINGCTL_USE_SCRIPT) +# include +# include +#elif !defined(SYNCTHINGCTL_NO_JSENGINE) +# error "No definition for JavaScript provider present." +#endif + +#endif // SYNCTHINGCTL_JAVA_SCRIPT_INCLUDES diff --git a/cli/testfiles/example.js b/cli/testfiles/example.js new file mode 100644 index 0000000..7db1417 --- /dev/null +++ b/cli/testfiles/example.js @@ -0,0 +1,27 @@ +// example script for changing configuration with syncthingctl +// can be executed like: syncthingctl edit --script example.js + +// alter some options +config.gui.useTLS = true; +config.options.relaysEnabled = false; + +// enable file system watcher for all folders starting with "docs-" +var folders = config.folders; +for (var i = 0, count = folders.length; i !== count; ++i) { + var folder = folders[i]; + if (folder.id.indexOf("docs-") === 0) { + //folder.fsWatcherDelayS = 50; + //folder.fsWatcherEnabled = true; + console.log("enabling file system watcher for folder " + folder.id); + } +} + +// ensure all devices are enabled +var devices = config.devices; +for (var i = 0, count = devices.length; i !== count; ++i) { + var device = devices[i]; + if (device.paused) { + device.paused = false; + console.log("unpausing device " + (device.name ? device.name : device.deviceID)); + } +} diff --git a/connector/syncthingconnection.cpp b/connector/syncthingconnection.cpp index e8ade71..4154b26 100644 --- a/connector/syncthingconnection.cpp +++ b/connector/syncthingconnection.cpp @@ -756,7 +756,7 @@ void SyncthingConnection::requestConfig() /*! * \brief Requests the Syncthing status asynchronously. * - * The signal configDirChanged() and myIdChanged() emitted when those values have changed; error() is emitted in the error case. + * The signals configDirChanged() and myIdChanged() are emitted when those values have changed; error() is emitted in the error case. */ void SyncthingConnection::requestStatus() { @@ -764,6 +764,17 @@ void SyncthingConnection::requestStatus() m_statusReply = requestData(QStringLiteral("system/status"), QUrlQuery()), &QNetworkReply::finished, this, &SyncthingConnection::readStatus); } +/*! + * \brief Requests the Syncthing configuration and status asynchronously. + * + * \sa requestConfig() and requestStatus() for emitted signals. + */ +void SyncthingConnection::requestConfigAndStatus() +{ + requestConfig(); + requestStatus(); +} + /*! * \brief Requests current connections asynchronously. * @@ -846,12 +857,24 @@ void SyncthingConnection::requestDeviceStatistics() * \remarks The signal newConfigTriggered() is emitted when the config has been posted sucessfully. In the error case, error() is emitted. * Besides, the newConfig() signal should be emitted as well, indicating Syncthing has actually applied the new configuration. */ -void SyncthingConnection::postConfig(const QJsonObject &rawConfig) +void SyncthingConnection::postConfigFromJsonObject(const QJsonObject &rawConfig) { QObject::connect(postData(QStringLiteral("system/config"), QUrlQuery(), QJsonDocument(rawConfig).toJson(QJsonDocument::Compact)), &QNetworkReply::finished, this, &SyncthingConnection::readPostConfig); } +/*! + * \brief Posts the specified \a rawConfig. + * \param rawConfig A valid JSON document containing the configuration. It is directly passed to Syncthing. + * \remarks The signal newConfigTriggered() is emitted when the config has been posted sucessfully. In the error case, error() is emitted. + * Besides, the newConfig() signal should be emitted as well, indicating Syncthing has actually applied the new configuration. + */ +void SyncthingConnection::postConfigFromByteArray(const QByteArray &rawConfig) +{ + QObject::connect( + postData(QStringLiteral("system/config"), QUrlQuery(), rawConfig), &QNetworkReply::finished, this, &SyncthingConnection::readPostConfig); +} + /*! * \brief Requests the Syncthing events (since the last successful call) asynchronously. * diff --git a/connector/syncthingconnection.h b/connector/syncthingconnection.h index fe2fa24..41e7ae9 100644 --- a/connector/syncthingconnection.h +++ b/connector/syncthingconnection.h @@ -165,6 +165,7 @@ public Q_SLOTS: void requestConfig(); void requestStatus(); + void requestConfigAndStatus(); void requestErrors(); void requestConnections(); void requestClearingErrors(); @@ -172,7 +173,8 @@ public Q_SLOTS: void requestDirStatus(const QString &dirId); void requestCompletion(const QString &devId, const QString &dirId); void requestDeviceStatistics(); - void postConfig(const QJsonObject &rawConfig); + void postConfigFromJsonObject(const QJsonObject &rawConfig); + void postConfigFromByteArray(const QByteArray &rawConfig); Q_SIGNALS: void newConfig(const QJsonObject &rawConfig);