From e9ecb63a9b797bc5bd635666ad59dd9ac648cb4c Mon Sep 17 00:00:00 2001 From: jwinterm Date: Thu, 18 Jun 2026 08:23:02 -0400 Subject: [PATCH] =?UTF-8?q?wowlet:=20embedded=20wownerod=20DaemonManager?= =?UTF-8?q?=20(modeled=20on=20TorManager)=20=E2=80=94=20spawn/monitor/grac?= =?UTF-8?q?eful-stop,=20run-own-node=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/WindowManager.cpp | 7 ++ src/utils/DaemonManager.cpp | 227 ++++++++++++++++++++++++++++++++++++ src/utils/DaemonManager.h | 80 +++++++++++++ src/utils/config.cpp | 2 + src/utils/config.h | 4 + 5 files changed, 320 insertions(+) create mode 100644 src/utils/DaemonManager.cpp create mode 100644 src/utils/DaemonManager.h diff --git a/src/WindowManager.cpp b/src/WindowManager.cpp index ac086218..af52eef3 100644 --- a/src/WindowManager.cpp +++ b/src/WindowManager.cpp @@ -22,6 +22,7 @@ #include "utils/os/tails.h" #include "utils/os/whonix.h" #include "utils/TorManager.h" +#include "utils/DaemonManager.h" #include "utils/WebsocketNotifier.h" #include "utils/AppData.h" @@ -47,6 +48,11 @@ WindowManager::WindowManager(QObject *parent) this->buildTrayMenu(); m_tray->setVisible(conf()->get(Config::showTrayIcon).toBool()); + // wowlet: boot the embedded wownerod node early so it syncs while the user opens their wallet. + // No-op if the user disabled the local node (Config::runLocalNode) or no daemon is bundled. + daemonManager()->init(); + daemonManager()->start(); + this->initSkins(); this->patchMacStylesheet(); @@ -107,6 +113,7 @@ void WindowManager::close() { } torManager()->stop(); + daemonManager()->stop(); // wowlet: gracefully shut down the embedded wownerod (flush LMDB) deleteLater(); diff --git a/src/utils/DaemonManager.cpp b/src/utils/DaemonManager.cpp new file mode 100644 index 00000000..603adf1f --- /dev/null +++ b/src/utils/DaemonManager.cpp @@ -0,0 +1,227 @@ +// SPDX-License-Identifier: BSD-3-Clause +// SPDX-FileCopyrightText: The Monero Project + +#include "utils/DaemonManager.h" + +#include +#include +#include +#include +#include + +#include "utils/config.h" +#include "utils/Utils.h" + +DaemonManager::DaemonManager(QObject *parent) + : QObject(parent) + , m_process(new QProcess(this)) +{ + this->daemonDir = Config::defaultConfigDir().filePath("node"); +#if defined(Q_OS_MACOS) + // On macOS the binary ships next to the app inside the bundle. + { + QDir appBinaryDir(QCoreApplication::applicationDirPath()); + appBinaryDir.cd(".."); + this->daemonDir = appBinaryDir.filePath("bin"); + } +#endif + + this->blockchainDir = Config::defaultConfigDir().filePath("node/blockchain"); + + m_process->setProcessChannelMode(QProcess::MergedChannels); + connect(m_process, &QProcess::readyReadStandardOutput, this, &DaemonManager::handleProcessOutput); + connect(m_process, &QProcess::errorOccurred, this, &DaemonManager::handleProcessError); + connect(m_process, &QProcess::stateChanged, this, &DaemonManager::onStateChanged); +} + +QPointer DaemonManager::m_instance(nullptr); + +QString DaemonManager::rpcAddress() const { + return QString("%1:%2").arg(this->rpcHost, QString::number(this->rpcPort)); +} + +void DaemonManager::init() { + // Make sure the data directory exists before wownerod tries to write to it. + QDir().mkpath(this->blockchainDir); +} + +void DaemonManager::start() { + if (!shouldStartDaemon()) + return; + + auto state = m_process->state(); + if (state == QProcess::ProcessState::Running || state == QProcess::ProcessState::Starting) + return; + + if (!this->unpackBins()) { + this->setErrorMessage("Unable to unpack the embedded wownerod binary."); + return; + } + + if (Utils::portOpen(this->rpcHost, this->rpcPort)) { + // Something is already listening on our RPC port; assume it is a usable local node. + m_alreadyRunning = true; + emit daemonStateChanged(true); + return; + } + + m_restarts += 1; + if (m_restarts > 4) { + this->setErrorMessage("wownerod failed to start: maximum retries exceeded"); + return; + } + + QStringList arguments; + arguments << "--data-dir" << this->blockchainDir; + arguments << "--rpc-bind-ip" << this->rpcHost; + arguments << "--rpc-bind-port" << QString::number(this->rpcPort); + arguments << "--p2p-bind-port" << QString::number(this->p2pPort); + arguments << "--non-interactive"; + arguments << "--no-igd"; // no UPnP port mapping + arguments << "--out-peers" << "16"; + arguments << "--log-file" << QDir(this->blockchainDir).filePath("wownerod.log"); + + // Full archival by default (wownero's chain is small); prune is an explicit opt-in. + if (conf()->get(Config::pruneBlockchain).toBool()) + arguments << "--prune-blockchain"; + + qDebug() << QString("Starting wownerod: %1 %2").arg(this->daemonPath, arguments.join(" ")); + + m_process->start(this->daemonPath, arguments); + m_started = true; +} + +void DaemonManager::stop() { + m_stopping = true; + if (m_process->state() == QProcess::ProcessState::NotRunning) { + m_started = false; + return; + } + + // wownerod owns an LMDB database. Send SIGTERM so it flushes and exits cleanly; only hard-kill + // if it refuses to shut down, to avoid blockchain database corruption. + m_process->terminate(); + if (!m_process->waitForFinished(30000)) { + qWarning() << "wownerod did not exit gracefully within 30s; killing"; + m_process->kill(); + m_process->waitForFinished(5000); + } + m_started = false; +} + +void DaemonManager::onStateChanged(QProcess::ProcessState state) { + if (state == QProcess::ProcessState::Running) { + this->setErrorMessage(""); + emit daemonStateChanged(true); + qWarning() << "wownerod started"; + } + else if (state == QProcess::ProcessState::NotRunning) { + emit daemonStateChanged(false); + if (m_stopping) + return; + + // Unexpected exit: try to bring it back up shortly. + QTimer::singleShot(2000, this, [this] { this->start(); }); + } +} + +void DaemonManager::handleProcessOutput() { + QByteArray output = m_process->readAllStandardOutput(); + this->logs.append(Utils::barrayToString(output)); + emit logsUpdated(); + + // wownerod reports sync progress as "Synced /". Surface it for the UI. + static const QRegularExpression syncRe(R"(Synced (\d+)\/(\d+))"); + QRegularExpressionMatch m = syncRe.match(QString::fromUtf8(output)); + if (m.hasMatch()) { + quint64 height = m.captured(1).toULongLong(); + quint64 target = m.captured(2).toULongLong(); + emit syncStatusChanged(height, target); + if (target > 0 && height >= target && !m_synced) { + m_synced = true; + emit synced(); + } + } +} + +void DaemonManager::handleProcessError(QProcess::ProcessError error) { + if (error == QProcess::ProcessError::Crashed) + qWarning() << "wownerod crashed or was killed"; + else if (error == QProcess::ProcessError::FailedToStart) + this->setErrorMessage("wownerod binary failed to start: " + this->daemonPath); +} + +bool DaemonManager::unpackBins() { + QString daemonBin = "wownerod"; +#if defined(Q_OS_WIN) + daemonBin += ".exe"; +#endif + + this->daemonPath = QDir(this->daemonDir).filePath(daemonBin); + +#if defined(Q_OS_MACOS) + // Shipped next to the app bundle; nothing to unpack. + return QFileInfo(this->daemonPath).isFile(); +#endif + + if (m_unpacked) + return true; + + if (QFileInfo(this->daemonPath).isFile()) { + m_unpacked = true; + return true; + } + + QDir().mkpath(this->daemonDir); + QDirIterator it(":/assets/wownerod", QDirIterator::Subdirectories); + while (it.hasNext()) { + QString assetFile = it.next(); + QFileInfo info(assetFile); + QFile f(assetFile); + f.copy(QDir(this->daemonDir).filePath(info.fileName())); + f.close(); + } + +#if defined(Q_OS_UNIX) + QFile bin(this->daemonPath); + bin.setPermissions(QFile::ExeUser | QFile::ExeGroup | QFile::ExeOther + | QFile::ReadOwner | QFile::ReadGroup | QFile::ReadOther); +#endif + + if (!QFileInfo(this->daemonPath).isFile()) { + qWarning() << "wownerod was not unpacked (built without embedded daemon?)"; + return false; + } + + m_unpacked = true; + return true; +} + +bool DaemonManager::shouldStartDaemon() { + m_alreadyRunning = false; + + // The headline feature: run our own node by default. Opt-out is an explicit setting. + if (!conf()->get(Config::runLocalNode).toBool()) + return false; + + return true; +} + +void DaemonManager::setErrorMessage(const QString &msg) { + this->errorMsg = msg; + if (!msg.isEmpty()) + qWarning() << "DaemonManager:" << msg; + emit statusChanged(msg); +} + +DaemonManager* DaemonManager::instance() +{ + if (!m_instance) { + m_instance = new DaemonManager(QCoreApplication::instance()); + } + return m_instance; +} + +DaemonManager::~DaemonManager() { + this->stop(); +} diff --git a/src/utils/DaemonManager.h b/src/utils/DaemonManager.h new file mode 100644 index 00000000..71389a70 --- /dev/null +++ b/src/utils/DaemonManager.h @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: BSD-3-Clause +// SPDX-FileCopyrightText: The Monero Project + +#ifndef WOWLET_DAEMONMANAGER_H +#define WOWLET_DAEMONMANAGER_H + +#include +#include +#include +#include + +// Manages an embedded wownerod node: unpacks the bundled binary, spawns it, monitors it, and stops +// it gracefully on exit. Modeled on Feather's TorManager (same bundle-binary + QProcess-lifecycle +// pattern), but wownerod owns an LMDB blockchain database, so shutdown is a graceful terminate (the +// daemon must flush), never a hard kill. +// +// wowlet runs this by default (the headline feature): the wallet connects to this local node at +// 127.0.0.1, and the embedded miner mines to it. Opt-out is a deliberate setting. +class DaemonManager : public QObject { +Q_OBJECT + +public: + explicit DaemonManager(QObject *parent = nullptr); + static DaemonManager* instance(); + ~DaemonManager() override; + + void init(); + void start(); + void stop(); + bool unpackBins(); + + bool isStarted() const { return m_started; } + bool isAlreadyRunning() const { return m_alreadyRunning; } + bool isSynced() const { return m_synced; } + + QString rpcAddress() const; // "127.0.0.1:" for the wallet to connect to + + QString daemonPath; + QString daemonDir; // where the unpacked wownerod binary lives + QString blockchainDir; // --data-dir (blockchain + p2pstate) + QString rpcHost = "127.0.0.1"; + quint16 rpcPort = 34568; // wownero default restricted-RPC port + quint16 p2pPort = 34567; // wownero default p2p port + + QStringList logs; + QString errorMsg; + +signals: + void daemonStateChanged(bool running); + void syncStatusChanged(quint64 height, quint64 targetHeight); + void synced(); + void statusChanged(const QString &reason); + void logsUpdated(); + +private slots: + void onStateChanged(QProcess::ProcessState state); + void handleProcessOutput(); + void handleProcessError(QProcess::ProcessError error); + +private: + bool shouldStartDaemon(); + void setErrorMessage(const QString &msg); + + QProcess *m_process; + bool m_started = false; + bool m_alreadyRunning = false; + bool m_unpacked = false; + bool m_synced = false; + bool m_stopping = false; + int m_restarts = 0; + + static QPointer m_instance; +}; + +inline DaemonManager* daemonManager() +{ + return DaemonManager::instance(); +} + +#endif //WOWLET_DAEMONMANAGER_H diff --git a/src/utils/config.cpp b/src/utils/config.cpp index c2efd7f3..ca2188bb 100644 --- a/src/utils/config.cpp +++ b/src/utils/config.cpp @@ -65,6 +65,8 @@ static const QHash configStrings = { {Config::lastSettingsPage, {QS("lastSettingsPage"), 0}}, {Config::preferredFiatCurrency,{QS("preferredFiatCurrency"), "USD"}}, {Config::skin,{QS("skin"), "Wownero"}}, + {Config::runLocalNode,{QS("runLocalNode"), true}}, // wowlet: run own node by default + {Config::pruneBlockchain,{QS("pruneBlockchain"), false}}, // full archival by default {Config::amountPrecision, {QS("amountPrecision"), 12}}, {Config::dateFormat, {QS("dateFormat"), "yyyy-MM-dd"}}, {Config::timeFormat, {QS("timeFormat"), "HH:mm"}}, diff --git a/src/utils/config.h b/src/utils/config.h index 04d9d759..82ad94d4 100644 --- a/src/utils/config.h +++ b/src/utils/config.h @@ -72,6 +72,10 @@ public: balanceShowFiat, preferredFiatCurrency, + // Embedded node (wowlet) + runLocalNode, + pruneBlockchain, + // Network -> Proxy proxy, socks5Host,