fix(mining): remediate mining-tab audit (22 findings) — crash-safety, async control, validation, math
Fixes all 22 confirmed findings from the mining-tab audit (10 Medium, 12 Low; 0 Critical/High), adversarially reviewed (6 follow-ups found + fixed, incl. the review-caught idle-auto-start bypass and a wrong benchmark-restore condition). Crash-safety & lifecycle: - M-04: join a stale/finished monitor thread in XmrigManager::start() and ~XmrigManager so an xmrig crash-then-restart (or quit) no longer std::terminate()s the wallet. - L-03/L-10: surface an unexpected miner exit once and clear the stale running flag. UI never blocks (M-03/L-06/L-08/L-09/L-13): pool start/stop now run on a dedicated serialized FIFO mining-control thread (joined before teardown), so the ~13 call sites don't block the render thread on stop()'s SIGTERM->SIGKILL->join; the spawn result marshals back to the UI. Miner-process / pool trust boundary: - M-01: validate the payout address (util::isValidRecipientAddress) at EVERY start path — the UI gate AND App::startPoolMining() (idle auto-start / thread scaling) — so a stale/wrong-chain address can't silently lose rewards. - M-09: SSRF guard skips the background pool-stats GET for loopback/private/link-local/single-label hosts. - M-02/L-02: cap the pool-stats + xmrig-API HTTP response bodies. - L-01: write the xmrig config 0600 at creation (POSIX open with mode) — no world/group-readable window. - M-10: reject shell-metacharacter binary paths before the version popen (excluding '()' so Program Files (x86) still works). Solo mining: M-06/M-08 clamp thread count to [1, cores] at the setgenerate/xmrig boundary; M-07 notify + don't lie on stop failure. Correctness: L-05 block-time constant 75->150s (chainparams); M-05 discloses pool-mode "Est. Daily" as a rough solo-equivalent; L-04/L-11/L-12 benchmark lifecycle (cancel on nav-away / mode-switch with restore, skip rebalance mid-benchmark); L-07 honor cancel mid-extract in both the xmrig and daemon updaters. Two new i18n keys back-filled across all 8 languages; CJK subset font rebuilt for the new glyphs. Verified across full-node, lite, and Windows builds; tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
@@ -886,6 +886,7 @@
|
||||
"mining_difficulty_copied": "Schwierigkeit kopiert",
|
||||
"mining_est_block": "Gesch. Block",
|
||||
"mining_est_daily": "Gesch. täglich",
|
||||
"mining_est_daily_pool_sub": "grobe Solo-Äquivalenz, vor Pool-Gebühr",
|
||||
"mining_filter_all": "Alle",
|
||||
"mining_filter_tip_all": "Alle Einnahmen anzeigen",
|
||||
"mining_filter_tip_pool": "Nur Pool-Einnahmen anzeigen",
|
||||
@@ -914,6 +915,7 @@
|
||||
"mining_open_in_explorer": "Im Explorer öffnen",
|
||||
"mining_payout_address": "Auszahlungsadresse",
|
||||
"mining_payout_foreign": "⚠ Diese Auszahlungsadresse befindet sich nicht in Ihrer aktuellen Wallet — geschürfte Belohnungen würden an eine andere Wallet gehen. Aktualisieren Sie sie, wenn Sie die Wallet gewechselt haben.",
|
||||
"mining_payout_invalid": "Keine gültige DragonX-Adresse — vor dem Start korrigieren, sonst gehen die Mining-Belohnungen verloren.",
|
||||
"mining_payout_tooltip": "Adresse für Mining-Belohnungen",
|
||||
"mining_pool": "Pool",
|
||||
"mining_pool_fee": "Gebühr",
|
||||
|
||||
@@ -886,6 +886,7 @@
|
||||
"mining_difficulty_copied": "Dificultad copiada",
|
||||
"mining_est_block": "Bloque Est.",
|
||||
"mining_est_daily": "Diario Est.",
|
||||
"mining_est_daily_pool_sub": "equivalente solo aproximado, antes de la comisión del pool",
|
||||
"mining_filter_all": "Todos",
|
||||
"mining_filter_tip_all": "Mostrar todas las ganancias",
|
||||
"mining_filter_tip_pool": "Mostrar solo ganancias del pool",
|
||||
@@ -914,6 +915,7 @@
|
||||
"mining_open_in_explorer": "Abrir en explorador",
|
||||
"mining_payout_address": "Dirección de Pago",
|
||||
"mining_payout_foreign": "⚠ Esta dirección de pago no está en tu cartera actual — las recompensas minadas irían a otra cartera. Actualízala si cambiaste de cartera.",
|
||||
"mining_payout_invalid": "No es una dirección DragonX válida — corrígela antes de empezar, o se pierden las recompensas de minería.",
|
||||
"mining_payout_tooltip": "Dirección para recibir recompensas de minería",
|
||||
"mining_pool": "Pool",
|
||||
"mining_pool_fee": "Comisión",
|
||||
|
||||
@@ -886,6 +886,7 @@
|
||||
"mining_difficulty_copied": "Difficulté copiée",
|
||||
"mining_est_block": "Bloc est.",
|
||||
"mining_est_daily": "Est. quotidien",
|
||||
"mining_est_daily_pool_sub": "équivalent solo approximatif, avant les frais du pool",
|
||||
"mining_filter_all": "Tout",
|
||||
"mining_filter_tip_all": "Afficher tous les gains",
|
||||
"mining_filter_tip_pool": "Afficher uniquement les gains du pool",
|
||||
@@ -914,6 +915,7 @@
|
||||
"mining_open_in_explorer": "Ouvrir dans l'explorateur",
|
||||
"mining_payout_address": "Adresse de paiement",
|
||||
"mining_payout_foreign": "⚠ Cette adresse de paiement ne fait pas partie de votre portefeuille actuel — les récompenses minées iraient vers un autre portefeuille. Mettez-la à jour si vous avez changé de portefeuille.",
|
||||
"mining_payout_invalid": "Adresse DragonX invalide — corrigez-la avant de démarrer, sinon les récompenses de minage sont perdues.",
|
||||
"mining_payout_tooltip": "Adresse pour recevoir les récompenses de minage",
|
||||
"mining_pool": "Pool",
|
||||
"mining_pool_fee": "Frais",
|
||||
|
||||
@@ -886,6 +886,7 @@
|
||||
"mining_difficulty_copied": "難易度をコピーしました",
|
||||
"mining_est_block": "予測ブロック",
|
||||
"mining_est_daily": "予測日収",
|
||||
"mining_est_daily_pool_sub": "おおよそのソロ換算(プール手数料前)",
|
||||
"mining_filter_all": "すべて",
|
||||
"mining_filter_tip_all": "すべての収益を表示",
|
||||
"mining_filter_tip_pool": "プール収益のみ表示",
|
||||
@@ -914,6 +915,7 @@
|
||||
"mining_open_in_explorer": "エクスプローラーで開く",
|
||||
"mining_payout_address": "支払いアドレス",
|
||||
"mining_payout_foreign": "⚠ この支払いアドレスは現在のウォレットに含まれていません — マイニング報酬が別のウォレットに送られます。ウォレットを切り替えた場合は更新してください。",
|
||||
"mining_payout_invalid": "有効な DragonX アドレスではありません — 開始前に修正してください。さもないとマイニング報酬が失われます。",
|
||||
"mining_payout_tooltip": "マイニング報酬の受取アドレス",
|
||||
"mining_pool": "プール",
|
||||
"mining_pool_fee": "手数料",
|
||||
|
||||
@@ -885,6 +885,7 @@
|
||||
"mining_difficulty_copied": "난이도가 복사되었습니다",
|
||||
"mining_est_block": "예상 블록",
|
||||
"mining_est_daily": "예상 일일 수익",
|
||||
"mining_est_daily_pool_sub": "대략적인 솔로 환산, 풀 수수료 전",
|
||||
"mining_filter_all": "전체",
|
||||
"mining_filter_tip_all": "모든 수익 표시",
|
||||
"mining_filter_tip_pool": "풀 수익만 표시",
|
||||
@@ -913,6 +914,7 @@
|
||||
"mining_open_in_explorer": "탐색기에서 열기",
|
||||
"mining_payout_address": "지급 주소",
|
||||
"mining_payout_foreign": "⚠ 이 지급 주소는 현재 지갑에 없습니다 — 채굴한 보상이 다른 지갑으로 전송됩니다. 지갑을 전환했다면 주소를 업데이트하세요.",
|
||||
"mining_payout_invalid": "유효한 DragonX 주소가 아닙니다 — 시작하기 전에 수정하세요. 그렇지 않으면 채굴 보상이 사라집니다.",
|
||||
"mining_payout_tooltip": "채굴 보상 수신 주소",
|
||||
"mining_pool": "풀",
|
||||
"mining_pool_fee": "수수료",
|
||||
|
||||
@@ -886,6 +886,7 @@
|
||||
"mining_difficulty_copied": "Dificuldade copiada",
|
||||
"mining_est_block": "Bloco Est.",
|
||||
"mining_est_daily": "Est. Diário",
|
||||
"mining_est_daily_pool_sub": "equivalente solo aproximado, antes da taxa do pool",
|
||||
"mining_filter_all": "Todos",
|
||||
"mining_filter_tip_all": "Mostrar todos os ganhos",
|
||||
"mining_filter_tip_pool": "Mostrar apenas ganhos do pool",
|
||||
@@ -914,6 +915,7 @@
|
||||
"mining_open_in_explorer": "Abrir no explorador",
|
||||
"mining_payout_address": "Endereço de Pagamento",
|
||||
"mining_payout_foreign": "⚠ Este endereço de pagamento não está na sua carteira atual — as recompensas mineradas iriam para uma carteira diferente. Atualize-o se você trocou de carteira.",
|
||||
"mining_payout_invalid": "Endereço DragonX inválido — corrija antes de iniciar, ou as recompensas de mineração serão perdidas.",
|
||||
"mining_payout_tooltip": "Endereço para receber recompensas de mineração",
|
||||
"mining_pool": "Pool",
|
||||
"mining_pool_fee": "Taxa",
|
||||
|
||||
@@ -886,6 +886,7 @@
|
||||
"mining_difficulty_copied": "Сложность скопирована",
|
||||
"mining_est_block": "Расч. блок",
|
||||
"mining_est_daily": "Расч. за день",
|
||||
"mining_est_daily_pool_sub": "примерный соло-эквивалент, до комиссии пула",
|
||||
"mining_filter_all": "Все",
|
||||
"mining_filter_tip_all": "Показать все доходы",
|
||||
"mining_filter_tip_pool": "Показать только доходы пула",
|
||||
@@ -914,6 +915,7 @@
|
||||
"mining_open_in_explorer": "Открыть в обозревателе",
|
||||
"mining_payout_address": "Адрес выплат",
|
||||
"mining_payout_foreign": "⚠ Этот адрес выплат отсутствует в вашем текущем кошельке — намайненные вознаграждения будут отправлены в другой кошелёк. Обновите его, если вы сменили кошелёк.",
|
||||
"mining_payout_invalid": "Недействительный адрес DragonX — исправьте перед запуском, иначе награды за майнинг будут потеряны.",
|
||||
"mining_payout_tooltip": "Адрес для получения вознаграждений за майнинг",
|
||||
"mining_pool": "Пул",
|
||||
"mining_pool_fee": "Комиссия",
|
||||
|
||||
@@ -885,6 +885,7 @@
|
||||
"mining_difficulty_copied": "难度已复制",
|
||||
"mining_est_block": "预计区块",
|
||||
"mining_est_daily": "预计日收益",
|
||||
"mining_est_daily_pool_sub": "粗略的单人挖矿等值,扣除矿池费用前",
|
||||
"mining_filter_all": "全部",
|
||||
"mining_filter_tip_all": "显示所有收益",
|
||||
"mining_filter_tip_pool": "仅显示矿池收益",
|
||||
@@ -913,6 +914,7 @@
|
||||
"mining_open_in_explorer": "在浏览器中打开",
|
||||
"mining_payout_address": "支付地址",
|
||||
"mining_payout_foreign": "⚠ 此支付地址不在您当前的钱包中——挖矿奖励将进入另一个钱包。如果您切换过钱包,请更新它。",
|
||||
"mining_payout_invalid": "不是有效的 DragonX 地址——启动前请更正,否则挖矿奖励将丢失。",
|
||||
"mining_payout_tooltip": "接收挖矿奖励的地址",
|
||||
"mining_pool": "矿池",
|
||||
"mining_pool_fee": "费用",
|
||||
|
||||
66
src/app.cpp
66
src/app.cpp
@@ -147,6 +147,45 @@ void App::wipeSecrets()
|
||||
sodium_memzero(import_key_input_, sizeof(import_key_input_)); // pasted private key (SECRET)
|
||||
}
|
||||
|
||||
// Enqueue a blocking xmrig start/stop op onto the dedicated serialized control thread so the render thread
|
||||
// never blocks on stop()'s SIGTERM->SIGKILL->join, while start/stop still execute in FIFO order. (M-03/…)
|
||||
void App::postMiningControl(std::function<void()> job)
|
||||
{
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mining_ctl_mutex_);
|
||||
if (mining_ctl_stop_) return; // shutting down — don't enqueue new mining ops
|
||||
if (!mining_ctl_thread_.joinable()) {
|
||||
mining_ctl_thread_ = std::thread([this]() {
|
||||
for (;;) {
|
||||
std::function<void()> j;
|
||||
{
|
||||
std::unique_lock<std::mutex> lk(mining_ctl_mutex_);
|
||||
mining_ctl_cv_.wait(lk, [this]{ return mining_ctl_stop_ || !mining_ctl_queue_.empty(); });
|
||||
if (mining_ctl_stop_) return; // abandon any pending jobs on shutdown
|
||||
j = std::move(mining_ctl_queue_.front());
|
||||
mining_ctl_queue_.pop_front();
|
||||
}
|
||||
j();
|
||||
}
|
||||
});
|
||||
}
|
||||
mining_ctl_queue_.push_back(std::move(job));
|
||||
}
|
||||
mining_ctl_cv_.notify_one();
|
||||
}
|
||||
|
||||
// Signal the mining-control thread to stop and join it. Called at shutdown BEFORE xmrig_manager_ is stopped
|
||||
// or destroyed, so no control job runs concurrently with teardown. Idempotent.
|
||||
void App::stopMiningControlThread()
|
||||
{
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mining_ctl_mutex_);
|
||||
mining_ctl_stop_ = true;
|
||||
}
|
||||
mining_ctl_cv_.notify_all();
|
||||
if (mining_ctl_thread_.joinable()) mining_ctl_thread_.join();
|
||||
}
|
||||
|
||||
namespace {
|
||||
// How often auto-balance re-evaluates the pool while active. Long, because switching
|
||||
// restarts the miner (drops in-flight shares + reconnect); the incumbent stickiness
|
||||
@@ -166,6 +205,7 @@ void App::updatePoolAutoBalance()
|
||||
if (!supportsPoolMining()) return; // pool mining is available in both builds (solo is full-node only)
|
||||
if (settings_->getPoolSelectMode() != config::Settings::PoolSelectMode::AutoBalance) return;
|
||||
if (!settings_->getPoolMode()) return; // only while POOL mode is selected
|
||||
if (ui::IsMiningBenchmarkActive()) return; // don't auto-switch pools mid-benchmark — it restarts xmrig at the wrong thread count (L-12)
|
||||
|
||||
const long long now = steadyNowMs();
|
||||
const bool intervalDue = (last_balance_eval_ms_ == 0) ||
|
||||
@@ -965,6 +1005,20 @@ void App::update()
|
||||
}
|
||||
}
|
||||
|
||||
// Surface an unexpected miner exit (crash / OOM-kill / external SIGKILL) once, and clear the stale
|
||||
// running flag so the UI and auto-balance don't keep believing it's still hashing. (L-03, L-10)
|
||||
if (xmrig_manager_ && state_.pool_mining.xmrig_running
|
||||
&& xmrig_manager_->getState() == daemon::XmrigManager::State::Error) {
|
||||
state_.pool_mining.xmrig_running = false;
|
||||
state_.pool_mining.hashrate_10s = 0.0;
|
||||
state_.pool_mining.hashrate_60s = 0.0;
|
||||
state_.pool_mining.hashrate_15m = 0.0;
|
||||
pool_starting_.store(false, std::memory_order_relaxed);
|
||||
const std::string err = xmrig_manager_->getLastError();
|
||||
ui::Notifications::instance().error(err.empty() ? "Miner stopped unexpectedly."
|
||||
: ("Miner stopped: " + err));
|
||||
}
|
||||
|
||||
// Poll xmrig stats every ~2 seconds (use a simple toggle)
|
||||
static bool xmrig_poll_tick = false;
|
||||
xmrig_poll_tick = !xmrig_poll_tick;
|
||||
@@ -1797,6 +1851,10 @@ void App::render()
|
||||
if ((current_page_ == ui::NavPage::Console || current_page_ == ui::NavPage::LiteConsole)
|
||||
&& settings_ && settings_->getConsoleAutoFocus())
|
||||
console_tab_.requestInputFocus();
|
||||
// Leaving the Mining tab → cancel a running thread benchmark so the miner isn't abandoned at a
|
||||
// benchmark step. (L-04)
|
||||
if (prev_page_ == ui::NavPage::Mining && current_page_ != ui::NavPage::Mining)
|
||||
ui::CancelMiningBenchmark(this);
|
||||
prev_page_ = current_page_;
|
||||
}
|
||||
if (page_alpha_ < 1.0f) {
|
||||
@@ -5595,6 +5653,10 @@ void App::beginShutdown()
|
||||
fast_worker_->requestStop();
|
||||
}
|
||||
|
||||
// Drain + join the mining-control thread FIRST so no async start/stop job runs while we tear the miner
|
||||
// down here (avoids two threads driving xmrig_manager_ during shutdown). (M-03 cluster)
|
||||
stopMiningControlThread();
|
||||
|
||||
// Stop xmrig pool miner before stopping the daemon
|
||||
if (xmrig_manager_ && xmrig_manager_->isRunning()) {
|
||||
shutdown_status_ = "Stopping pool miner...";
|
||||
@@ -6380,6 +6442,10 @@ void App::renderLoadingOverlay(float contentH)
|
||||
|
||||
void App::shutdown()
|
||||
{
|
||||
// Ensure the mining-control thread is stopped + joined (idempotent; beginShutdown already did it on the
|
||||
// normal quit path, but shutdown() can also run without it). Must precede xmrig_manager_ teardown.
|
||||
stopMiningControlThread();
|
||||
|
||||
// Wipe any copied secret from the OS clipboard before we exit — the 45s auto-clear timer
|
||||
// never fires if the user quits sooner, which would otherwise leave a key/seed resident.
|
||||
// (ImGui context is still alive here; App::shutdown() runs before ImGui::DestroyContext().)
|
||||
|
||||
11
src/app.h
11
src/app.h
@@ -14,6 +14,7 @@
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <deque>
|
||||
#include <condition_variable>
|
||||
#include <nlohmann/json_fwd.hpp>
|
||||
#include "data/transaction_history_cache.h"
|
||||
#include "data/address_book.h"
|
||||
@@ -855,6 +856,16 @@ private:
|
||||
bool lite_startup_lock_checked_ = false;
|
||||
std::unique_ptr<daemon::DaemonController> daemon_controller_;
|
||||
std::unique_ptr<daemon::XmrigManager> xmrig_manager_;
|
||||
// Serialized async mining-control queue: xmrig start/stop (SIGTERM->SIGKILL->join, up to ~3s) run on
|
||||
// this dedicated FIFO thread instead of the render thread, so the UI never blocks and stop/start
|
||||
// ordering is preserved across the ~13 call sites. (M-03/L-06/L-08/L-09/L-13)
|
||||
std::thread mining_ctl_thread_;
|
||||
std::mutex mining_ctl_mutex_;
|
||||
std::condition_variable mining_ctl_cv_;
|
||||
std::deque<std::function<void()>> mining_ctl_queue_;
|
||||
bool mining_ctl_stop_ = false;
|
||||
void postMiningControl(std::function<void()> job); // enqueue a blocking xmrig op onto the FIFO thread
|
||||
void stopMiningControlThread(); // signal + join the control thread (shutdown)
|
||||
// Auto-balance runtime state (pool mining, full-node only). The service fetches
|
||||
// pool hashrates off-thread; the RNG drives the weighted-random pick.
|
||||
util::PoolStatsService pool_stats_service_;
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
#include <cstdio> // popen the rebuild helper
|
||||
#include "util/perf_log.h"
|
||||
#include "util/i18n.h"
|
||||
#include "util/address_validation.h" // isValidRecipientAddress — payout validation at every start path (M-01)
|
||||
#include "util/secure_vault.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
@@ -2338,6 +2339,10 @@ void App::startMining(int threads)
|
||||
return;
|
||||
}
|
||||
if (!state_.connected || !rpc_ || !worker_) return;
|
||||
// Clamp the requested thread count to [1, logical cores] before setgenerate — an unclamped value
|
||||
// (from a settings field or idle-scaling) would ask the daemon to spawn arbitrarily many threads. (M-08)
|
||||
const int maxThreads = std::max(1, (int)std::thread::hardware_concurrency());
|
||||
threads = std::clamp(threads, 1, maxThreads);
|
||||
if (mining_toggle_in_progress_.exchange(true)) return; // already in progress
|
||||
|
||||
worker_->post([this, threads]() -> rpc::RPCWorker::MainCb {
|
||||
@@ -2372,19 +2377,25 @@ void App::stopMining()
|
||||
|
||||
worker_->post([this]() -> rpc::RPCWorker::MainCb {
|
||||
bool ok = false;
|
||||
std::string errMsg;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Mining tab / Stop mining");
|
||||
rpc_->call("setgenerate", {false, 0});
|
||||
ok = true;
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("Failed to stop mining: %s\n", e.what());
|
||||
errMsg = e.what();
|
||||
DEBUG_LOGF("Failed to stop mining: %s\n", errMsg.c_str());
|
||||
}
|
||||
return [this, ok]() {
|
||||
return [this, ok, errMsg]() {
|
||||
mining_toggle_in_progress_.store(false);
|
||||
if (ok) {
|
||||
state_.mining.generate = false;
|
||||
state_.mining.localHashrate = 0.0;
|
||||
DEBUG_LOGF("Mining stopped\n");
|
||||
} else {
|
||||
// Don't silently leave generate=true as if it worked: tell the user and let the next
|
||||
// getmininginfo refresh reconcile the true daemon state. (M-07)
|
||||
ui::Notifications::instance().error("Failed to stop mining: " + errMsg);
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -2397,21 +2408,28 @@ void App::startPoolMining(int threads)
|
||||
ui::Notifications::instance().warning("Pool mining is unavailable in this build");
|
||||
return;
|
||||
}
|
||||
// Clamp to [1, logical cores] before the count reaches xmrig (M-06/M-08 pool path).
|
||||
threads = std::clamp(threads, 1, std::max(1, (int)std::thread::hardware_concurrency()));
|
||||
|
||||
if (!xmrig_manager_)
|
||||
xmrig_manager_ = std::make_unique<daemon::XmrigManager>();
|
||||
|
||||
// If already running, stop first (e.g. thread count change)
|
||||
if (xmrig_manager_->isRunning()) {
|
||||
xmrig_manager_->stop();
|
||||
}
|
||||
|
||||
// Stop solo mining first if active
|
||||
// Stop solo mining first if active (async via the RPC worker).
|
||||
if (state_.mining.generate) stopMining();
|
||||
// (the "stop the already-running miner first" step is done inside the control job below, in FIFO order)
|
||||
|
||||
daemon::XmrigManager::Config cfg;
|
||||
cfg.pool_url = settings_->getPoolUrl();
|
||||
cfg.worker_name = settings_->getPoolWorker();
|
||||
// Validate the payout address at EVERY start entry point (manual Start button, idle auto-start, thread
|
||||
// scaling) — not just the UI gate — since a stale/hand-edited/wrong-chain address here silently loses
|
||||
// mining rewards. (M-01) worker_name IS the pool login the rewards are credited to (see below).
|
||||
if (!cfg.worker_name.empty() && cfg.worker_name != "x" &&
|
||||
!util::isValidRecipientAddress(cfg.worker_name)) {
|
||||
ui::Notifications::instance().error(
|
||||
"Pool payout address is not a valid DragonX address — mining not started.");
|
||||
return;
|
||||
}
|
||||
// The algo follows the pool: official pools use their own algo (pool.dragonx.cc
|
||||
// needs rx/dragonx, pool.dragonx.is rx/hush); custom hosts keep the setting.
|
||||
cfg.algo = util::resolvePoolAlgo(cfg.pool_url, settings_->getPoolAlgo());
|
||||
@@ -2443,34 +2461,46 @@ void App::startPoolMining(int threads)
|
||||
return;
|
||||
}
|
||||
|
||||
if (!xmrig_manager_->start(cfg)) {
|
||||
std::string err = xmrig_manager_->getLastError();
|
||||
DEBUG_LOGF("[ERROR] Pool mining: %s\n", err.c_str());
|
||||
|
||||
// Check for Windows Defender blocking (error 225 = ERROR_VIRUS_INFECTED)
|
||||
if (err.find("error 225") != std::string::npos ||
|
||||
err.find("virus") != std::string::npos) {
|
||||
ui::Notifications::instance().error(
|
||||
"Windows Defender blocked xmrig. Add exclusion for %APPDATA%\\ObsidianDragon");
|
||||
// Run the blocking stop(if running)+start on the serialized mining-control thread so the render thread
|
||||
// never blocks on stop()'s SIGTERM->SIGKILL->join; marshal the spawn result back to the UI. (M-03/L-06/
|
||||
// L-08/L-09/L-13). cfg was fully built above on this (main) thread.
|
||||
daemon::XmrigManager::Config cfgCopy = cfg;
|
||||
postMiningControl([this, cfgCopy]() {
|
||||
if (xmrig_manager_->isRunning()) xmrig_manager_->stop(3000);
|
||||
const bool ok = xmrig_manager_->start(cfgCopy);
|
||||
const std::string err = ok ? std::string() : xmrig_manager_->getLastError();
|
||||
if (!worker_) return;
|
||||
worker_->post([this, ok, err]() -> rpc::RPCWorker::MainCb {
|
||||
return [this, ok, err]() {
|
||||
if (ok) {
|
||||
// Miner spawned — it still needs a few seconds to connect to the pool and start hashing.
|
||||
pool_starting_.store(true, std::memory_order_relaxed);
|
||||
ui::Notifications::instance().info("Starting pool miner — connecting to the pool…");
|
||||
} else {
|
||||
DEBUG_LOGF("[ERROR] Pool mining: %s\n", err.c_str());
|
||||
// Windows Defender blocking (error 225 = ERROR_VIRUS_INFECTED)
|
||||
if (err.find("error 225") != std::string::npos || err.find("virus") != std::string::npos) {
|
||||
ui::Notifications::instance().error(
|
||||
"Windows Defender blocked xmrig. Add exclusion for %APPDATA%\\ObsidianDragon");
|
||||
#ifdef _WIN32
|
||||
// Offer to open Windows Security settings
|
||||
pending_antivirus_dialog_ = true;
|
||||
pending_antivirus_dialog_ = true;
|
||||
#endif
|
||||
} else {
|
||||
ui::Notifications::instance().error("Failed to start pool miner: " + err);
|
||||
}
|
||||
} else {
|
||||
// Miner spawned — it still needs a few seconds to connect to the pool and start hashing.
|
||||
pool_starting_.store(true, std::memory_order_relaxed);
|
||||
ui::Notifications::instance().info("Starting pool miner — connecting to the pool…");
|
||||
}
|
||||
} else {
|
||||
ui::Notifications::instance().error("Failed to start pool miner: " + err);
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void App::stopPoolMining()
|
||||
{
|
||||
if (xmrig_manager_ && xmrig_manager_->isRunning()) {
|
||||
xmrig_manager_->stop(3000);
|
||||
}
|
||||
if (!xmrig_manager_) return;
|
||||
// Off the render thread — stop()'s SIGTERM->SIGKILL->join can block up to ~3s. (M-03/L-06/L-08/L-09)
|
||||
postMiningControl([this]() {
|
||||
if (xmrig_manager_->isRunning()) xmrig_manager_->stop(3000);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -759,6 +759,10 @@ void App::checkIdleMining() {
|
||||
// Resolve auto values: active defaults to half, idle defaults to all
|
||||
if (activeThreads <= 0) activeThreads = std::max(1, maxThreads / 2);
|
||||
if (idleThreads <= 0) idleThreads = maxThreads;
|
||||
// Clamp to [1, logical cores] before these reach setgenerate / startPoolMining — a settings field
|
||||
// could otherwise carry an arbitrary count straight past every bound. (M-06)
|
||||
activeThreads = std::clamp(activeThreads, 1, maxThreads);
|
||||
idleThreads = std::clamp(idleThreads, 1, maxThreads);
|
||||
|
||||
if (systemIdle) {
|
||||
// System is idle — scale up to idle thread count
|
||||
|
||||
@@ -89,8 +89,32 @@ static std::string getConfigDir() {
|
||||
// libcurl write callback
|
||||
static size_t curlWriteCb(void* ptr, size_t sz, size_t n, void* userdata) {
|
||||
auto* s = static_cast<std::string*>(userdata);
|
||||
s->append(static_cast<char*>(ptr), sz * n);
|
||||
return sz * n;
|
||||
const size_t add = sz * n;
|
||||
// Stats JSON (local xmrig HTTP API + pool API) is tiny; refuse an unbounded body from a hostile or
|
||||
// MITM'd endpoint so it can't grow this string until OOM. Returning < add aborts the transfer. (L-02)
|
||||
constexpr size_t kMaxStatsBytes = 1u << 20; // 1 MiB
|
||||
if (s->size() + add > kMaxStatsBytes) return 0;
|
||||
s->append(static_cast<char*>(ptr), add);
|
||||
return add;
|
||||
}
|
||||
|
||||
// True if `host` (already stripped of scheme+port) is a loopback/private/link-local/single-label target
|
||||
// that a public mining pool would never be — used to refuse a background stats GET to it (M-09).
|
||||
static bool hostLooksInternal(const std::string& host) {
|
||||
if (host.empty() || host == "localhost") return true;
|
||||
if (host.rfind("127.", 0) == 0 || host.rfind("10.", 0) == 0 ||
|
||||
host.rfind("192.168.", 0) == 0 || host.rfind("169.254.", 0) == 0) return true;
|
||||
if (host.rfind("172.", 0) == 0) { // 172.16.0.0 - 172.31.255.255
|
||||
const int second = std::atoi(host.c_str() + 4);
|
||||
if (second >= 16 && second <= 31) return true;
|
||||
}
|
||||
if (host.find(':') != std::string::npos) { // IPv6 literal: loopback / ULA / link-local
|
||||
if (host == "::1" || host.rfind("fc", 0) == 0 || host.rfind("fd", 0) == 0 ||
|
||||
host.rfind("fe80", 0) == 0) return true;
|
||||
}
|
||||
if (host.size() >= 6 && host.compare(host.size() - 6, 6, ".local") == 0) return true;
|
||||
if (host.find('.') == std::string::npos) return true; // bare single-label name = LAN/hosts, not a pool
|
||||
return false;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -100,9 +124,14 @@ static size_t curlWriteCb(void* ptr, size_t sz, size_t n, void* userdata) {
|
||||
XmrigManager::XmrigManager() = default;
|
||||
|
||||
XmrigManager::~XmrigManager() {
|
||||
should_stop_ = true;
|
||||
if (isRunning()) {
|
||||
stop(3000);
|
||||
}
|
||||
// Join a monitor thread left joinable by an unexpected xmrig exit (State::Error, so isRunning() is
|
||||
// false and stop() above was skipped) — std::thread's destructor would otherwise std::terminate(). (M-04)
|
||||
if (monitor_thread_.joinable())
|
||||
monitor_thread_.join();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -208,21 +237,41 @@ bool XmrigManager::generateConfig(const Config& cfg, const std::string& outPath)
|
||||
|
||||
try {
|
||||
fs::create_directories(fs::path(outPath).parent_path());
|
||||
const std::string dumped = j.dump(4);
|
||||
#ifndef _WIN32
|
||||
// Create the config 0600 AT CREATION (open with mode) so the API token + wallet address are never
|
||||
// in a world/group-readable file — even for a local attacker who opened it in the old
|
||||
// create-then-chmod window and held the fd open across the chmod. (L-01)
|
||||
int fd = ::open(outPath.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0600);
|
||||
if (fd < 0) {
|
||||
setLastError("Cannot write xmrig config: " + outPath);
|
||||
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
|
||||
return false;
|
||||
}
|
||||
size_t off = 0;
|
||||
bool wrote = true;
|
||||
while (off < dumped.size()) {
|
||||
ssize_t nw = ::write(fd, dumped.data() + off, dumped.size() - off);
|
||||
if (nw <= 0) { wrote = false; break; }
|
||||
off += static_cast<size_t>(nw);
|
||||
}
|
||||
::close(fd);
|
||||
if (!wrote) {
|
||||
setLastError("Cannot write xmrig config: " + outPath);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
#else
|
||||
std::ofstream ofs(outPath, std::ios::trunc);
|
||||
if (!ofs.is_open()) {
|
||||
setLastError("Cannot write xmrig config: " + outPath);
|
||||
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
|
||||
return false;
|
||||
}
|
||||
#ifndef _WIN32
|
||||
// Restrict to owner (0600) BEFORE writing any secret material (API token, wallet
|
||||
// address, worker name). The file is still empty here, so the config is never
|
||||
// world-readable — closing the window between creation and the previous post-write chmod.
|
||||
chmod(outPath.c_str(), 0600);
|
||||
#endif
|
||||
ofs << j.dump(4);
|
||||
ofs << dumped;
|
||||
ofs.close();
|
||||
return true;
|
||||
#endif
|
||||
} catch (const std::exception& e) {
|
||||
setLastError(std::string("Config write error: ") + e.what());
|
||||
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
|
||||
@@ -296,7 +345,11 @@ bool XmrigManager::start(const Config& cfg) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Start monitor thread
|
||||
// Join a prior monitor thread before move-assigning: if xmrig exited unexpectedly, monitorProcess set
|
||||
// State::Error and returned, leaving monitor_thread_ joinable — move-assigning over a joinable
|
||||
// std::thread calls std::terminate() and aborts the whole wallet. (M-04)
|
||||
if (monitor_thread_.joinable())
|
||||
monitor_thread_.join();
|
||||
monitor_thread_ = std::thread(&XmrigManager::monitorProcess, this);
|
||||
state_ = State::Running;
|
||||
DEBUG_LOGF("[INFO] XmrigManager: started\n");
|
||||
@@ -783,6 +836,11 @@ void XmrigManager::fetchPoolApiStats() {
|
||||
// own API shape (pool.dragonx.is = custom /api/stats; pool.dragonx.cc = Miningcore
|
||||
// /api/pools); unknown/custom hosts fall back to the .is convention.
|
||||
const util::KnownPool* known = util::findKnownPoolByUrl(pool_host_);
|
||||
// SSRF guard: for an UNKNOWN (user-typed) pool host, don't let the wallet issue a background GET to a
|
||||
// loopback/private/link-local/single-label target — those aren't public mining pools, and a
|
||||
// paste-a-pool-config lure could otherwise point us at an internal host. Known pools use their trusted
|
||||
// registry statsUrl and are exempt. (M-09)
|
||||
if (!known && hostLooksInternal(pool_host_)) return;
|
||||
const std::string url = known ? known->statsUrl
|
||||
: ("https://" + pool_host_ + "/api/stats");
|
||||
|
||||
@@ -861,7 +919,14 @@ void XmrigManager::startVersionDetection()
|
||||
std::thread([]() {
|
||||
const std::string bin = findXmrigBinary();
|
||||
std::string ver;
|
||||
if (!bin.empty()) {
|
||||
// Don't hand a path containing shell/cmd metacharacters to popen()'s shell — bin is normally an
|
||||
// app-controlled path, but this closes command injection if it ever isn't. (M-10)
|
||||
// Reject only chars that stay shell-special INSIDE the double-quotes we wrap bin in ("\"" + bin + "\"")
|
||||
// on cmd.exe or /bin/sh. Parens are inert when quoted, so they're excluded — otherwise common Windows
|
||||
// paths like "C:\Program Files (x86)\..." would be rejected and version detection would silently fail. (M-10)
|
||||
const bool binShellSafe =
|
||||
!bin.empty() && bin.find_first_of("\"'`$;&|<>^%\n\r") == std::string::npos;
|
||||
if (binShellSafe) {
|
||||
const std::string cmd = "\"" + bin + "\" --version 2>&1";
|
||||
#ifdef _WIN32
|
||||
FILE* fp = _popen(cmd.c_str(), "r");
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include "../../config/settings.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../../util/platform.h"
|
||||
#include "../../util/address_validation.h" // isValidRecipientAddress — payout validation (M-01)
|
||||
#include "../schema/ui_schema.h"
|
||||
#include "../material/type.h"
|
||||
#include "../material/draw_helpers.h"
|
||||
@@ -685,8 +686,12 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
|
||||
bool poolStillRunning = !s_pool_mode && state.pool_mining.xmrig_running;
|
||||
// Can't start pool mining without a payout address (blank for a new wallet with no z-address);
|
||||
// only blocks starting — stopping a running miner stays enabled.
|
||||
// Block start when the payout address is empty OR not a valid DragonX address — mining to a
|
||||
// malformed / wrong-chain address silently loses the rewards. (M-01)
|
||||
const std::string poolPayoutStr(s_pool_worker);
|
||||
bool poolNeedsPayout = s_pool_mode && !state.pool_mining.xmrig_running &&
|
||||
std::string(s_pool_worker).empty();
|
||||
(poolPayoutStr.empty() ||
|
||||
(poolPayoutStr != "x" && !util::isValidRecipientAddress(poolPayoutStr)));
|
||||
bool disabled = s_pool_mode
|
||||
? (isToggling || poolBlockedBySolo || poolNeedsPayout)
|
||||
: (poolStillRunning ? false : (!app->isConnected() || isToggling || isSyncing));
|
||||
|
||||
@@ -104,7 +104,10 @@ void RenderMiningEarnings(App* app, const WalletState& state, const MiningInfo&
|
||||
}
|
||||
}
|
||||
|
||||
// Use pool hashrate for EST. DAILY when in pool mode
|
||||
// Est. Daily = expected reward for YOUR own hashrate share of the network. In pool mode this uses
|
||||
// your local miner rate (pool_mining.hashrate_10s), NOT the pool's aggregate rate: pool payouts are
|
||||
// share-proportional, so your expected daily is the same solo-equivalent value (a rough estimate,
|
||||
// before the pool fee). Using the pool's total rate here would show the POOL's earnings, not yours. (M-05)
|
||||
double estHashrate = s_pool_mode ? state.pool_mining.hashrate_10s : mining.localHashrate;
|
||||
double est_hours_2 = EstimateHoursToBlock(estHashrate, mining.networkHashrate, mining.difficulty);
|
||||
double estDailyBlocks = (est_hours_2 > 0) ? (24.0 / est_hours_2) : 0.0;
|
||||
@@ -218,12 +221,15 @@ void RenderMiningEarnings(App* app, const WalletState& state, const MiningInfo&
|
||||
snprintf(estVal, sizeof(estVal), "~%.4f", estDaily);
|
||||
else
|
||||
snprintf(estVal, sizeof(estVal), "N/A");
|
||||
// Disclose in pool mode that Est. Daily is a rough solo-equivalent (before the pool fee), so the
|
||||
// number isn't silently mismatched to its plain "Est. Daily" label. (M-05)
|
||||
const char* estSub = (s_pool_mode && estActive) ? TR("mining_est_daily_pool_sub") : nullptr;
|
||||
|
||||
EarningsEntry entries[] = {
|
||||
{ TR("mining_today"), todayVal, todaySub, greenCol2 },
|
||||
{ TR("mining_yesterday"), yesterdayVal, yesterdaySub, OnSurface() },
|
||||
{ TR("mining_all_time"), allVal, allSub, OnSurface() },
|
||||
{ TR("mining_est_daily"), estVal, nullptr, estActive ? greenCol2 : OnSurfaceDisabled() },
|
||||
{ TR("mining_est_daily"), estVal, estSub, estActive ? greenCol2 : OnSurfaceDisabled() },
|
||||
};
|
||||
|
||||
for (int ei = 0; ei < numCols; ei++) {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#include "mining_mode_toggle.h"
|
||||
#include "mining_tab_helpers.h"
|
||||
#include "mining_tab.h" // CancelMiningBenchmark (L-11)
|
||||
#include "mining_pool_panel.h"
|
||||
|
||||
#include "../../app.h"
|
||||
@@ -16,6 +17,7 @@
|
||||
#include "../material/type.h"
|
||||
#include "../material/draw_helpers.h"
|
||||
#include "../material/colors.h"
|
||||
#include "../../util/address_validation.h" // isValidRecipientAddress — payout validation (M-01)
|
||||
#include "../layout.h"
|
||||
#include "../notifications.h"
|
||||
#include "../../embedded/IconsMaterialDesign.h"
|
||||
@@ -101,6 +103,7 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
|
||||
s_pool_mode = false;
|
||||
app->settings()->setPoolMode(false);
|
||||
app->settings()->save();
|
||||
CancelMiningBenchmark(app); // don't leave a pool benchmark running after switching to solo (L-11)
|
||||
app->stopPoolMining();
|
||||
}
|
||||
if (soloHov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
@@ -382,6 +385,8 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
|
||||
std::string currentWorkerStr(s_pool_worker);
|
||||
if (currentWorkerStr.empty()) {
|
||||
material::Tooltip("%s", TR("mining_generate_z_address_hint"));
|
||||
} else if (currentWorkerStr != "x" && !util::isValidRecipientAddress(currentWorkerStr)) {
|
||||
material::Tooltip("%s", TR("mining_payout_invalid")); // block start below too (M-01)
|
||||
} else {
|
||||
material::Tooltip("%s", TR("mining_payout_tooltip"));
|
||||
}
|
||||
|
||||
@@ -55,6 +55,19 @@ bool IsMiningBenchmarkActive() {
|
||||
return s_benchmark.active();
|
||||
}
|
||||
|
||||
void CancelMiningBenchmark(App* app) {
|
||||
if (!s_benchmark.active()) return;
|
||||
const int restoreThreads = s_benchmark.prev_threads;
|
||||
s_benchmark.reset();
|
||||
// Restore the miner to its pre-benchmark thread count. A benchmark runs in pool mode, so restore
|
||||
// regardless of the instantaneous running state — the sweep may be mid inter-candidate stop, where an
|
||||
// isPoolMinerRunning() check would be transiently false and silently drop the restart. (L-04, L-11)
|
||||
if (app && restoreThreads > 0) {
|
||||
app->stopPoolMining();
|
||||
app->startPoolMining(restoreThreads);
|
||||
}
|
||||
}
|
||||
|
||||
// Miner-update version check (one shot per session): fetches the latest DRG-XMRig release tag in
|
||||
// the background so the "Update" button can show it. Network call to the project Gitea, started
|
||||
// the first time the pool section is shown.
|
||||
|
||||
@@ -21,5 +21,12 @@ void RenderMiningTab(App* app);
|
||||
*/
|
||||
bool IsMiningBenchmarkActive();
|
||||
|
||||
/**
|
||||
* @brief Cancel a running thread benchmark and restore the miner to its pre-benchmark thread count.
|
||||
* Safe to call when no benchmark is active (no-op). Used when leaving the Mining tab or switching to
|
||||
* solo mode so the miner isn't left stuck at a benchmark step. (L-04, L-11)
|
||||
*/
|
||||
void CancelMiningBenchmark(App* app);
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
|
||||
@@ -60,7 +60,7 @@ double EstimateHoursToBlock(double localHashrate, double networkHashrate, double
|
||||
{
|
||||
(void)difficulty;
|
||||
if (localHashrate <= 0.0 || networkHashrate <= 0.0) return 0.0;
|
||||
double blocksPerHour = 3600.0 / 75.0;
|
||||
double blocksPerHour = 3600.0 / 150.0; // DragonX mainnet target spacing is 150s (chainparams) (L-05)
|
||||
double share = localHashrate / networkHashrate;
|
||||
if (share <= 0.0) return 0.0;
|
||||
return 1.0 / (blocksPerHour * share);
|
||||
|
||||
@@ -354,6 +354,7 @@ void DaemonUpdater::installResolved(const std::string& targetDir, const DaemonRe
|
||||
bool failed = false;
|
||||
const int numFiles = static_cast<int>(mz_zip_reader_get_num_files(&zip));
|
||||
for (int i = 0; i < numFiles && !failed; ++i) {
|
||||
if (cancel_requested_) { failed = true; break; } // honor cancel mid-extraction so the join returns promptly (L-07)
|
||||
mz_zip_archive_file_stat st;
|
||||
if (!mz_zip_reader_file_stat(&zip, i, &st)) continue;
|
||||
if (mz_zip_reader_is_file_a_directory(&zip, i)) continue;
|
||||
|
||||
@@ -1880,6 +1880,8 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["mining_open_in_explorer"] = "Open in explorer";
|
||||
strings_["mining_payout_address"] = "Payout Address";
|
||||
strings_["mining_payout_tooltip"] = "Address to receive mining rewards";
|
||||
strings_["mining_payout_invalid"] = "Not a valid DragonX address — fix it before starting, or mining rewards are lost.";
|
||||
strings_["mining_est_daily_pool_sub"] = "rough solo-equivalent, before pool fee";
|
||||
strings_["mining_generate_z_address_hint"] = "Generate a Z address in the Receive tab to use as your payout address";
|
||||
strings_["mining_pool"] = "Pool";
|
||||
strings_["mining_payout_foreign"] = "⚠ This payout address isn't in your current wallet — mined rewards would go to a different wallet. Update it if you switched wallets.";
|
||||
|
||||
@@ -13,8 +13,14 @@ namespace {
|
||||
|
||||
size_t writeStringCb(void* contents, size_t size, size_t nmemb, void* userp)
|
||||
{
|
||||
static_cast<std::string*>(userp)->append(static_cast<char*>(contents), size * nmemb);
|
||||
return size * nmemb;
|
||||
auto* s = static_cast<std::string*>(userp);
|
||||
const size_t add = size * nmemb;
|
||||
// Pool stats JSON is tiny; refuse an unbounded body from a hostile/MITM'd endpoint (returning < add
|
||||
// aborts the transfer) so it can't grow this string until OOM. (M-02)
|
||||
constexpr size_t kMaxPoolStatsBytes = 1u << 20; // 1 MiB
|
||||
if (s->size() + add > kMaxPoolStatsBytes) return 0;
|
||||
s->append(static_cast<char*>(contents), add);
|
||||
return add;
|
||||
}
|
||||
|
||||
// Returning non-zero asks libcurl to abort the transfer — used so shutdown doesn't
|
||||
|
||||
@@ -345,6 +345,7 @@ void XmrigUpdater::installResolved(const std::string& targetDir, const XmrigRele
|
||||
bool failed = false;
|
||||
const int numFiles = static_cast<int>(mz_zip_reader_get_num_files(&zip));
|
||||
for (int i = 0; i < numFiles && !failed; ++i) {
|
||||
if (cancel_requested_) { failed = true; break; } // honor cancel mid-extraction so the dialog's join returns promptly (L-07)
|
||||
mz_zip_archive_file_stat st;
|
||||
if (!mz_zip_reader_file_stat(&zip, i, &st)) continue;
|
||||
if (mz_zip_reader_is_file_a_directory(&zip, i)) continue;
|
||||
|
||||
Reference in New Issue
Block a user