Allow editing config via syncthingctl
This commit is contained in:
parent
ac36e6955d
commit
c7ea5974e4
|
@ -19,7 +19,9 @@
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QJsonDocument>
|
#include <QJsonDocument>
|
||||||
#include <QNetworkAccessManager>
|
#include <QNetworkAccessManager>
|
||||||
|
#include <QProcess>
|
||||||
#include <QStringBuilder>
|
#include <QStringBuilder>
|
||||||
|
#include <QTemporaryFile>
|
||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
|
|
||||||
#include <functional>
|
#include <functional>
|
||||||
|
@ -79,6 +81,7 @@ Application::Application()
|
||||||
m_args.waitForIdle.setCallback(bind(&Application::waitForIdle, this, _1));
|
m_args.waitForIdle.setCallback(bind(&Application::waitForIdle, this, _1));
|
||||||
m_args.pwd.setCallback(bind(&Application::checkPwdOperationPresent, this, _1));
|
m_args.pwd.setCallback(bind(&Application::checkPwdOperationPresent, this, _1));
|
||||||
m_args.cat.setCallback(bind(&Application::printConfig, this, _1));
|
m_args.cat.setCallback(bind(&Application::printConfig, this, _1));
|
||||||
|
m_args.edit.setCallback(bind(&Application::editConfig, this, _1));
|
||||||
m_args.statusPwd.setCallback(bind(&Application::printPwdStatus, this, _1));
|
m_args.statusPwd.setCallback(bind(&Application::printPwdStatus, this, _1));
|
||||||
m_args.rescanPwd.setCallback(bind(&Application::requestRescanPwd, this, _1));
|
m_args.rescanPwd.setCallback(bind(&Application::requestRescanPwd, this, _1));
|
||||||
m_args.pausePwd.setCallback(bind(&Application::requestPausePwd, this, _1));
|
m_args.pausePwd.setCallback(bind(&Application::requestPausePwd, this, _1));
|
||||||
|
@ -576,11 +579,114 @@ void Application::printLog(const std::vector<SyncthingLogEntry> &logEntries)
|
||||||
|
|
||||||
void Application::printConfig(const ArgumentOccurrence &)
|
void Application::printConfig(const ArgumentOccurrence &)
|
||||||
{
|
{
|
||||||
waitForConfig();
|
// 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
|
||||||
eraseLine(cout);
|
|
||||||
cout << '\r' << QJsonDocument(m_connection.rawConfig()).toJson().data() << flush;
|
|
||||||
// disable main event loop since this method is invoked directly as argument callback and we've done all async operations during the waitForConfig() call already
|
|
||||||
m_requiresMainEventLoop = false;
|
m_requiresMainEventLoop = false;
|
||||||
|
|
||||||
|
if (!waitForConfig()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cerr << Phrases::Override;
|
||||||
|
|
||||||
|
cout << QJsonDocument(m_connection.rawConfig()).toJson(QJsonDocument::Indented).data() << flush;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
QStringList editorOptions;
|
||||||
|
if (m_args.editor.isPresent()) {
|
||||||
|
const auto &editorArgValues(m_args.editor.values());
|
||||||
|
if (!editorArgValues.empty()) {
|
||||||
|
editorOptions.reserve(trQuandity(editorArgValues.size()));
|
||||||
|
for (auto i = editorArgValues.cbegin() + 1, end = editorArgValues.cend(); i != end; ++i) {
|
||||||
|
editorOptions << QString::fromLocal8Bit(*i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
editorOptions << tempFile.fileName();
|
||||||
|
tempFile.close();
|
||||||
|
|
||||||
|
// open editor and wait until it has finished
|
||||||
|
cerr << Phrases::Info << "Waiting till editor closed ..." << TextAttribute::Reset << flush;
|
||||||
|
QProcess editor;
|
||||||
|
editor.setProcessChannelMode(QProcess::ForwardedChannels);
|
||||||
|
editor.setInputChannelMode(QProcess::ForwardedInputChannel);
|
||||||
|
editor.start(editorCommand, editorOptions);
|
||||||
|
editor.waitForFinished(-1);
|
||||||
|
cerr << Phrases::Override;
|
||||||
|
|
||||||
|
// handle editor crash
|
||||||
|
if (editor.exitStatus() == QProcess::CrashExit) {
|
||||||
|
cerr << Phrases::Error << "Editor crashed with exit code " << editor.exitCode() << Phrases::End << "invocation command: " << editorArgValue;
|
||||||
|
if (m_args.editor.isPresent()) {
|
||||||
|
const auto &editorArgValues(m_args.editor.values());
|
||||||
|
if (!editorArgValues.empty()) {
|
||||||
|
for (auto i = editorArgValues.cbegin() + 1, end = editorArgValues.cend(); i != end; ++i) {
|
||||||
|
cerr << ' ' << *i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cerr << endl;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
QJsonParseError error;
|
||||||
|
const auto configDoc(QJsonDocument::fromJson(newConfig, &error));
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
const auto configObj(configDoc.object());
|
||||||
|
if (configObj.isEmpty()) {
|
||||||
|
cerr << Phrases::Error << "New config object seems empty." << Phrases::EndFlush;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
void Application::waitForIdle(const ArgumentOccurrence &)
|
void Application::waitForIdle(const ArgumentOccurrence &)
|
||||||
|
|
|
@ -66,6 +66,7 @@ private:
|
||||||
void printStatus(const ArgumentOccurrence &);
|
void printStatus(const ArgumentOccurrence &);
|
||||||
static void printLog(const std::vector<Data::SyncthingLogEntry> &logEntries);
|
static void printLog(const std::vector<Data::SyncthingLogEntry> &logEntries);
|
||||||
void printConfig(const ArgumentOccurrence &);
|
void printConfig(const ArgumentOccurrence &);
|
||||||
|
void editConfig(const ArgumentOccurrence &);
|
||||||
void waitForIdle(const ArgumentOccurrence &);
|
void waitForIdle(const ArgumentOccurrence &);
|
||||||
bool checkWhetherIdle() const;
|
bool checkWhetherIdle() const;
|
||||||
void checkPwdOperationPresent(const ArgumentOccurrence &occurrence);
|
void checkPwdOperationPresent(const ArgumentOccurrence &occurrence);
|
||||||
|
|
12
cli/args.cpp
12
cli/args.cpp
|
@ -16,7 +16,8 @@ Args::Args()
|
||||||
, resume("resume", '\0', "resumes the specified directories and devices")
|
, resume("resume", '\0', "resumes the specified directories and devices")
|
||||||
, waitForIdle("wait-for-idle", 'w', "waits until the specified dirs/devs are idling")
|
, waitForIdle("wait-for-idle", 'w', "waits until the specified dirs/devs are idling")
|
||||||
, pwd("pwd", 'p', "operates in the current working directory")
|
, pwd("pwd", 'p', "operates in the current working directory")
|
||||||
, cat("cat", '\0', "prints the current configuration")
|
, cat("cat", '\0', "prints the current Syncthing configuration")
|
||||||
|
, edit("edit", '\0', "allows editing the Syncthing configuration using an external editor")
|
||||||
, statusPwd("status", 's', "prints the status of the current working directory")
|
, statusPwd("status", 's', "prints the status of the current working directory")
|
||||||
, rescanPwd("rescan", 'r', "rescans the current working directory")
|
, rescanPwd("rescan", 'r', "rescans the current working directory")
|
||||||
, pausePwd("pause", 'p', "pauses the current working directory")
|
, pausePwd("pause", 'p', "pauses the current working directory")
|
||||||
|
@ -28,6 +29,7 @@ Args::Args()
|
||||||
, atLeast("at-least", 'a', "specifies for how many milliseconds Syncthing must idle (prevents exiting too early in case of flaky status)",
|
, atLeast("at-least", 'a', "specifies for how many milliseconds Syncthing must idle (prevents exiting too early in case of flaky status)",
|
||||||
{ "number" })
|
{ "number" })
|
||||||
, timeout("timeout", 't', "specifies how many milliseconds to wait at most", { "number" })
|
, timeout("timeout", 't', "specifies how many milliseconds to wait at most", { "number" })
|
||||||
|
, editor("editor", '\0', "specifies the editor to be opened", { "editor name", "editor option" })
|
||||||
, configFile("config-file", 'f', "specifies the Syncthing config file to read API key and URL from, when not explicitely specified", { "path" })
|
, configFile("config-file", 'f', "specifies the Syncthing config file to read API key and URL from, when not explicitely specified", { "path" })
|
||||||
, apiKey("api-key", 'k', "specifies the API key", { "key" })
|
, apiKey("api-key", 'k', "specifies the API key", { "key" })
|
||||||
, url("url", 'u', "specifies the Syncthing URL, default is http://localhost:8080", { "URL" })
|
, url("url", 'u', "specifies the Syncthing URL, default is http://localhost:8080", { "URL" })
|
||||||
|
@ -45,6 +47,7 @@ Args::Args()
|
||||||
waitForIdle.setExample(PROJECT_NAME " wait-for-idle --timeout 1800000 --at-least 5000 && systemctl poweroff\n" PROJECT_NAME
|
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");
|
" wait-for-idle --dir dir1 --dir dir2 --dev dev1 --dev dev2 --at-least 5000");
|
||||||
pwd.setSubArguments({ &statusPwd, &rescanPwd, &pausePwd, &resumePwd });
|
pwd.setSubArguments({ &statusPwd, &rescanPwd, &pausePwd, &resumePwd });
|
||||||
|
edit.setSubArguments({ &editor });
|
||||||
|
|
||||||
rescan.setValueNames({ "dir ID" });
|
rescan.setValueNames({ "dir ID" });
|
||||||
rescan.setRequiredValueCount(Argument::varValueCount);
|
rescan.setRequiredValueCount(Argument::varValueCount);
|
||||||
|
@ -56,11 +59,14 @@ Args::Args()
|
||||||
resume.setSubArguments({ &dir, &dev, &allDirs, &allDevs });
|
resume.setSubArguments({ &dir, &dev, &allDirs, &allDevs });
|
||||||
resume.setExample(PROJECT_NAME " resume --dir dir1 --dir dir2 --dev dev1 --dev dev2\n" PROJECT_NAME " resume --all-devs");
|
resume.setExample(PROJECT_NAME " resume --dir dir1 --dir dir2 --dev dev1 --dev dev2\n" PROJECT_NAME " resume --all-devs");
|
||||||
|
|
||||||
|
editor.setEnvironmentVariable("EDITOR");
|
||||||
|
editor.setRequiredValueCount(Argument::varValueCount);
|
||||||
|
|
||||||
configFile.setExample(PROJECT_NAME " status --dir dir1 --config-file ~/.config/syncthing/config.xml");
|
configFile.setExample(PROJECT_NAME " status --dir dir1 --config-file ~/.config/syncthing/config.xml");
|
||||||
credentials.setExample(PROJECT_NAME " status --dir dir1 --credentials name supersecret");
|
credentials.setExample(PROJECT_NAME " status --dir dir1 --credentials name supersecret");
|
||||||
|
|
||||||
parser.setMainArguments({ &status, &log, &stop, &restart, &rescan, &rescanAll, &pause, &resume, &waitForIdle, &pwd, &cat, &configFile, &apiKey,
|
parser.setMainArguments({ &status, &log, &stop, &restart, &rescan, &rescanAll, &pause, &resume, &waitForIdle, &pwd, &cat, &edit, &configFile,
|
||||||
&url, &credentials, &certificate, &noColor, &help });
|
&apiKey, &url, &credentials, &certificate, &noColor, &help });
|
||||||
|
|
||||||
// allow setting default values via environment
|
// allow setting default values via environment
|
||||||
configFile.setEnvironmentVariable("SYNCTHING_CTL_CONFIG_FILE");
|
configFile.setEnvironmentVariable("SYNCTHING_CTL_CONFIG_FILE");
|
||||||
|
|
|
@ -12,10 +12,11 @@ struct Args {
|
||||||
ArgumentParser parser;
|
ArgumentParser parser;
|
||||||
HelpArgument help;
|
HelpArgument help;
|
||||||
NoColorArgument noColor;
|
NoColorArgument noColor;
|
||||||
OperationArgument status, log, stop, restart, rescan, rescanAll, pause, resume, waitForIdle, pwd, cat;
|
OperationArgument status, log, stop, restart, rescan, rescanAll, pause, resume, waitForIdle, pwd, cat, edit;
|
||||||
OperationArgument statusPwd, rescanPwd, pausePwd, resumePwd;
|
OperationArgument statusPwd, rescanPwd, pausePwd, resumePwd;
|
||||||
ConfigValueArgument dir, dev, allDirs, allDevs;
|
ConfigValueArgument dir, dev, allDirs, allDevs;
|
||||||
ConfigValueArgument atLeast, timeout;
|
ConfigValueArgument atLeast, timeout;
|
||||||
|
ConfigValueArgument editor;
|
||||||
ConfigValueArgument configFile, apiKey, url, credentials, certificate;
|
ConfigValueArgument configFile, apiKey, url, credentials, certificate;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -827,6 +827,17 @@ void SyncthingConnection::requestDeviceStatistics()
|
||||||
requestData(QStringLiteral("stats/device"), QUrlQuery()), &QNetworkReply::finished, this, &SyncthingConnection::readDeviceStatistics);
|
requestData(QStringLiteral("stats/device"), QUrlQuery()), &QNetworkReply::finished, this, &SyncthingConnection::readDeviceStatistics);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*!
|
||||||
|
* \brief Posts the specified \a rawConfig.
|
||||||
|
* \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)
|
||||||
|
{
|
||||||
|
QObject::connect(postData(QStringLiteral("system/config"), QUrlQuery(), QJsonDocument(rawConfig).toJson(QJsonDocument::Compact)),
|
||||||
|
&QNetworkReply::finished, this, &SyncthingConnection::readPostConfig);
|
||||||
|
}
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
* \brief Requests the Syncthing events (since the last successful call) asynchronously.
|
* \brief Requests the Syncthing events (since the last successful call) asynchronously.
|
||||||
*
|
*
|
||||||
|
@ -1897,6 +1908,19 @@ void SyncthingConnection::readRemoteIndexUpdated(DateTime eventTime, const QJson
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void SyncthingConnection::readPostConfig()
|
||||||
|
{
|
||||||
|
auto *const reply = static_cast<QNetworkReply *>(sender());
|
||||||
|
reply->deleteLater();
|
||||||
|
switch (reply->error()) {
|
||||||
|
case QNetworkReply::NoError:
|
||||||
|
emit newConfigTriggered();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
emitError(tr("Unable to post config: "), SyncthingErrorCategory::SpecificRequest, reply);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
* \brief Reads results of rescan().
|
* \brief Reads results of rescan().
|
||||||
*/
|
*/
|
||||||
|
@ -2340,6 +2364,12 @@ void SyncthingConnection::recalculateStatus()
|
||||||
* \brief Indicates totalIncomingTraffic() or totalOutgoingTraffic() has changed.
|
* \brief Indicates totalIncomingTraffic() or totalOutgoingTraffic() has changed.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/*!
|
||||||
|
* \fn SyncthingConnection::newConfigTriggered()
|
||||||
|
* \brief Indicates a new configuration has posted sucessfully via postConfig().
|
||||||
|
* \remarks In contrast to newConfig(), this signal is only emitted for configuration changes internally posted via postConfig().
|
||||||
|
*/
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
* \fn SyncthingConnection::rescanTriggered()
|
* \fn SyncthingConnection::rescanTriggered()
|
||||||
* \brief Indicates a rescan has been triggered sucessfully.
|
* \brief Indicates a rescan has been triggered sucessfully.
|
||||||
|
|
|
@ -79,6 +79,7 @@ class LIB_SYNCTHING_CONNECTOR_EXPORT SyncthingConnection : public QObject {
|
||||||
Q_PROPERTY(double totalIncomingRate READ totalIncomingRate NOTIFY trafficChanged)
|
Q_PROPERTY(double totalIncomingRate READ totalIncomingRate NOTIFY trafficChanged)
|
||||||
Q_PROPERTY(double totalOutgoingRate READ totalOutgoingRate NOTIFY trafficChanged)
|
Q_PROPERTY(double totalOutgoingRate READ totalOutgoingRate NOTIFY trafficChanged)
|
||||||
Q_PROPERTY(std::size_t connectedDevices READ connectedDevices)
|
Q_PROPERTY(std::size_t connectedDevices READ connectedDevices)
|
||||||
|
Q_PROPERTY(QJsonObject rawConfig READ rawConfig NOTIFY newConfig)
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit SyncthingConnection(
|
explicit SyncthingConnection(
|
||||||
|
@ -162,9 +163,10 @@ public Q_SLOTS:
|
||||||
void requestDirStatus(const QString &dirId);
|
void requestDirStatus(const QString &dirId);
|
||||||
void requestCompletion(const QString &devId, const QString &dirId);
|
void requestCompletion(const QString &devId, const QString &dirId);
|
||||||
void requestDeviceStatistics();
|
void requestDeviceStatistics();
|
||||||
|
void postConfig(const QJsonObject &rawConfig);
|
||||||
|
|
||||||
Q_SIGNALS:
|
Q_SIGNALS:
|
||||||
void newConfig(const QJsonObject &config);
|
void newConfig(const QJsonObject &rawConfig);
|
||||||
void newDirs(const std::vector<SyncthingDir> &dirs);
|
void newDirs(const std::vector<SyncthingDir> &dirs);
|
||||||
void newDevices(const std::vector<SyncthingDev> &devs);
|
void newDevices(const std::vector<SyncthingDev> &devs);
|
||||||
void newEvents(const QJsonArray &events);
|
void newEvents(const QJsonArray &events);
|
||||||
|
@ -179,6 +181,7 @@ Q_SIGNALS:
|
||||||
void configDirChanged(const QString &newConfigDir);
|
void configDirChanged(const QString &newConfigDir);
|
||||||
void myIdChanged(const QString &myNewId);
|
void myIdChanged(const QString &myNewId);
|
||||||
void trafficChanged(uint64 totalIncomingTraffic, uint64 totalOutgoingTraffic);
|
void trafficChanged(uint64 totalIncomingTraffic, uint64 totalOutgoingTraffic);
|
||||||
|
void newConfigTriggered();
|
||||||
void rescanTriggered(const QString &dirId);
|
void rescanTriggered(const QString &dirId);
|
||||||
void devicePauseTriggered(const QStringList &devIds);
|
void devicePauseTriggered(const QStringList &devIds);
|
||||||
void deviceResumeTriggered(const QStringList &devIds);
|
void deviceResumeTriggered(const QStringList &devIds);
|
||||||
|
@ -217,6 +220,7 @@ private Q_SLOTS:
|
||||||
void readRemoteFolderCompletion(
|
void readRemoteFolderCompletion(
|
||||||
ChronoUtilities::DateTime eventTime, const QJsonObject &eventData, SyncthingDir &dirInfo, int index, const QString &devId);
|
ChronoUtilities::DateTime eventTime, const QJsonObject &eventData, SyncthingDir &dirInfo, int index, const QString &devId);
|
||||||
void readRemoteIndexUpdated(ChronoUtilities::DateTime eventTime, const QJsonObject &eventData);
|
void readRemoteIndexUpdated(ChronoUtilities::DateTime eventTime, const QJsonObject &eventData);
|
||||||
|
void readPostConfig();
|
||||||
void readRescan();
|
void readRescan();
|
||||||
void readDevPauseResume();
|
void readDevPauseResume();
|
||||||
void readDirPauseResume();
|
void readDirPauseResume();
|
||||||
|
|
Loading…
Reference in New Issue