Allow editing Syncthing config via JavaScript

This commit is contained in:
Martchus 2018-04-07 22:01:54 +02:00
parent bf4b26c6f8
commit b61592fbbd
12 changed files with 295 additions and 35 deletions

View File

@ -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)

View File

@ -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 <c++utilities/application/failure.h>
#include <c++utilities/chrono/timespan.h>
#include <c++utilities/conversion/stringconversion.h>
@ -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 &)

View File

@ -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<Data::SyncthingLogEntry> &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);

View File

@ -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);

View File

@ -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;

14
cli/jsconsole.cpp Normal file
View File

@ -0,0 +1,14 @@
#include "./jsconsole.h"
#include <iostream>
using namespace std;
JSConsole::JSConsole(QObject *parent) : QObject(parent)
{
}
void JSConsole::log(const QString &msg) const
{
cerr << "script: "<< msg.toLocal8Bit().data() << endl;
}

16
cli/jsconsole.h Normal file
View File

@ -0,0 +1,16 @@
#ifndef CLI_JS_CONSOLE_H
#define CLI_JS_CONSOLE_H
#include <QObject>
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

36
cli/jsdefs.h Normal file
View File

@ -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 <QtGlobal>
#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

19
cli/jsincludes.h Normal file
View File

@ -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 <QtGlobal>
#if defined(SYNCTHINGCTL_USE_JSENGINE)
# include <QJSEngine>
# include <QJSValue>
#elif defined(SYNCTHINGCTL_USE_SCRIPT)
# include <QScriptEngine>
# include <QScriptValue>
#elif !defined(SYNCTHINGCTL_NO_JSENGINE)
# error "No definition for JavaScript provider present."
#endif
#endif // SYNCTHINGCTL_JAVA_SCRIPT_INCLUDES

27
cli/testfiles/example.js Normal file
View File

@ -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));
}
}

View File

@ -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.
*

View File

@ -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);