mirror of
https://github.com/ByteWelder/Tactility.git
synced 2026-02-18 10:53:17 +00:00
Dashboard auto refresh toggle (#465)
asset sync now works (AssetVersion)...could probably do with some improvements. disable auth username and password if not enabled.
This commit is contained in:
parent
d551e467b8
commit
f6ddb14ec1
@ -14,9 +14,14 @@
|
|||||||
}
|
}
|
||||||
.container { max-width: 1000px; margin: 0 auto; padding: 20px; }
|
.container { max-width: 1000px; margin: 0 auto; padding: 20px; }
|
||||||
header {
|
header {
|
||||||
text-align: center;
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
padding: 20px 0;
|
padding: 20px 0;
|
||||||
}
|
}
|
||||||
|
.header-left {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
header h1 {
|
header h1 {
|
||||||
font-size: 1.8em;
|
font-size: 1.8em;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@ -26,6 +31,95 @@
|
|||||||
color: #7f8c8d;
|
color: #7f8c8d;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
|
.header-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.refresh-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
.refresh-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
.refresh-btn:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
.refresh-btn.spinning svg {
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
.auto-refresh-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
.auto-refresh-toggle label {
|
||||||
|
color: #95a5a6;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.toggle-switch {
|
||||||
|
position: relative;
|
||||||
|
width: 40px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
.toggle-switch input {
|
||||||
|
opacity: 0;
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.toggle-slider {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 22px;
|
||||||
|
transition: 0.3s;
|
||||||
|
}
|
||||||
|
.toggle-slider:before {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
left: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: 0.3s;
|
||||||
|
}
|
||||||
|
.toggle-switch input:checked + .toggle-slider {
|
||||||
|
background: #3498db;
|
||||||
|
}
|
||||||
|
.toggle-switch input:checked + .toggle-slider:before {
|
||||||
|
transform: translateX(18px);
|
||||||
|
}
|
||||||
|
.countdown {
|
||||||
|
color: #7f8c8d;
|
||||||
|
font-size: 0.8em;
|
||||||
|
min-width: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
/* Tab Navigation */
|
/* Tab Navigation */
|
||||||
.tabs {
|
.tabs {
|
||||||
@ -330,7 +424,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
|
header { flex-direction: column; gap: 15px; }
|
||||||
|
.header-left { text-align: center; }
|
||||||
header h1 { font-size: 1.4em; }
|
header h1 { font-size: 1.4em; }
|
||||||
|
.header-controls { width: 100%; justify-content: center; }
|
||||||
.grid { grid-template-columns: 1fr; }
|
.grid { grid-template-columns: 1fr; }
|
||||||
.tabs { flex-wrap: wrap; }
|
.tabs { flex-wrap: wrap; }
|
||||||
.tab { padding: 10px 16px; font-size: 0.85em; }
|
.tab { padding: 10px 16px; font-size: 0.85em; }
|
||||||
@ -344,8 +441,25 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<header>
|
<header>
|
||||||
<h1>Tactility Dashboard</h1>
|
<div class="header-left">
|
||||||
<p class="subtitle" id="version">Loading...</p>
|
<h1>Tactility Dashboard</h1>
|
||||||
|
<p class="subtitle" id="version">Loading...</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-controls">
|
||||||
|
<div class="auto-refresh-toggle">
|
||||||
|
<label for="autoRefreshToggle">Auto</label>
|
||||||
|
<div class="toggle-switch">
|
||||||
|
<input type="checkbox" id="autoRefreshToggle" checked>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</div>
|
||||||
|
<span class="countdown" id="countdown">30s</span>
|
||||||
|
</div>
|
||||||
|
<button class="refresh-btn" onclick="manualRefresh()" title="Refresh now">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 2v6h-6M3 12a9 9 0 0 1 15-6.7L21 8M3 22v-6h6M21 12a9 9 0 0 1-15 6.7L3 16"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
@ -408,7 +522,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
Tactility WebServer - Auto-refreshes every 30 seconds
|
Tactility WebServer
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -420,6 +534,20 @@ function showTab(tabName) {
|
|||||||
document.querySelector(`.tab[onclick="showTab('${tabName}')"]`).classList.add('active');
|
document.querySelector(`.tab[onclick="showTab('${tabName}')"]`).classList.add('active');
|
||||||
document.getElementById(`${tabName}-tab`).classList.add('active');
|
document.getElementById(`${tabName}-tab`).classList.add('active');
|
||||||
|
|
||||||
|
// Pause/resume auto-refresh based on active tab to avoid misleading countdown
|
||||||
|
if (tabName === 'dashboard') {
|
||||||
|
// Resume auto-refresh if toggle is on
|
||||||
|
if (autoRefreshToggle.checked && !refreshInterval) {
|
||||||
|
startAutoRefresh();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Stop auto-refresh when leaving dashboard (countdown would be misleading)
|
||||||
|
// Toggle state is preserved, so it will resume when returning to dashboard
|
||||||
|
if (refreshInterval) {
|
||||||
|
stopAutoRefresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (tabName === 'files' && !filesLoaded) {
|
if (tabName === 'files' && !filesLoaded) {
|
||||||
refreshFiles();
|
refreshFiles();
|
||||||
filesLoaded = true;
|
filesLoaded = true;
|
||||||
@ -438,7 +566,10 @@ function handleHashChange() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('hashchange', handleHashChange);
|
window.addEventListener('hashchange', handleHashChange);
|
||||||
if (window.location.hash) handleHashChange();
|
if (window.location.hash) {
|
||||||
|
// Defer until auto-refresh elements are initialized to avoid ReferenceError
|
||||||
|
setTimeout(handleHashChange, 0);
|
||||||
|
}
|
||||||
|
|
||||||
// Utility functions
|
// Utility functions
|
||||||
function formatBytes(bytes) {
|
function formatBytes(bytes) {
|
||||||
@ -607,7 +738,6 @@ function renderDashboard(data) {
|
|||||||
<h2>Quick Actions</h2>
|
<h2>Quick Actions</h2>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn btn-primary" onclick="syncAssets(this)">Sync Assets</button>
|
<button class="btn btn-primary" onclick="syncAssets(this)">Sync Assets</button>
|
||||||
<button class="btn btn-secondary" onclick="location.reload()">Refresh</button>
|
|
||||||
${data.features_enabled?.screenshot ? '<button class="btn btn-secondary" onclick="captureScreenshot(this)">Screenshot</button>' : ''}
|
${data.features_enabled?.screenshot ? '<button class="btn btn-secondary" onclick="captureScreenshot(this)">Screenshot</button>' : ''}
|
||||||
<button class="btn btn-danger" onclick="rebootDevice(this)">Reboot</button>
|
<button class="btn btn-danger" onclick="rebootDevice(this)">Reboot</button>
|
||||||
</div>
|
</div>
|
||||||
@ -733,12 +863,80 @@ async function captureScreenshot(btn) {
|
|||||||
btn.textContent = 'Screenshot';
|
btn.textContent = 'Screenshot';
|
||||||
}
|
}
|
||||||
let refreshInterval;
|
let refreshInterval;
|
||||||
|
let countdownInterval;
|
||||||
|
let countdownValue = 30;
|
||||||
|
const REFRESH_INTERVAL_SECONDS = 30;
|
||||||
|
|
||||||
|
const autoRefreshToggle = document.getElementById('autoRefreshToggle');
|
||||||
|
const countdownEl = document.getElementById('countdown');
|
||||||
|
|
||||||
|
function updateCountdown() {
|
||||||
|
countdownValue--;
|
||||||
|
if (countdownValue <= 0) {
|
||||||
|
countdownValue = REFRESH_INTERVAL_SECONDS;
|
||||||
|
}
|
||||||
|
countdownEl.textContent = countdownValue + 's';
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAutoRefresh() {
|
||||||
|
countdownValue = REFRESH_INTERVAL_SECONDS;
|
||||||
|
countdownEl.textContent = countdownValue + 's';
|
||||||
|
countdownEl.style.display = '';
|
||||||
|
|
||||||
|
countdownInterval = setInterval(updateCountdown, 1000);
|
||||||
|
refreshInterval = setInterval(() => {
|
||||||
|
if (document.getElementById('dashboard-tab').classList.contains('active')) {
|
||||||
|
loadDashboard();
|
||||||
|
countdownValue = REFRESH_INTERVAL_SECONDS;
|
||||||
|
}
|
||||||
|
}, REFRESH_INTERVAL_SECONDS * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAutoRefresh() {
|
||||||
|
if (refreshInterval) clearInterval(refreshInterval);
|
||||||
|
if (countdownInterval) clearInterval(countdownInterval);
|
||||||
|
refreshInterval = null;
|
||||||
|
countdownInterval = null;
|
||||||
|
countdownEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
autoRefreshToggle.addEventListener('change', () => {
|
||||||
|
if (autoRefreshToggle.checked) {
|
||||||
|
// Only start if on dashboard tab to avoid misleading countdown
|
||||||
|
if (document.getElementById('dashboard-tab').classList.contains('active') && !refreshInterval) {
|
||||||
|
startAutoRefresh();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stopAutoRefresh();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function manualRefresh() {
|
||||||
|
const btn = document.querySelector('.refresh-btn');
|
||||||
|
btn.classList.add('spinning');
|
||||||
|
|
||||||
|
const activeTab = document.querySelector('.tab-content.active');
|
||||||
|
if (activeTab.id === 'dashboard-tab') {
|
||||||
|
await loadDashboard();
|
||||||
|
if (autoRefreshToggle.checked) {
|
||||||
|
countdownValue = REFRESH_INTERVAL_SECONDS;
|
||||||
|
}
|
||||||
|
} else if (activeTab.id === 'files-tab') {
|
||||||
|
await refreshFiles();
|
||||||
|
} else if (activeTab.id === 'apps-tab') {
|
||||||
|
await loadApps();
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.classList.remove('spinning');
|
||||||
|
}
|
||||||
|
|
||||||
async function rebootDevice(btn) {
|
async function rebootDevice(btn) {
|
||||||
if (!confirm('Reboot device now?')) return;
|
if (!confirm('Reboot device now?')) return;
|
||||||
const status = document.getElementById('actionStatus');
|
const status = document.getElementById('actionStatus');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Rebooting...';
|
btn.textContent = 'Rebooting...';
|
||||||
if (refreshInterval) clearInterval(refreshInterval);
|
stopAutoRefresh();
|
||||||
|
autoRefreshToggle.checked = false;
|
||||||
try {
|
try {
|
||||||
await fetch('/admin/reboot', { method: 'POST' });
|
await fetch('/admin/reboot', { method: 'POST' });
|
||||||
} catch (e) { }
|
} catch (e) { }
|
||||||
@ -1137,11 +1335,7 @@ async function installAppFile(file) {
|
|||||||
|
|
||||||
// Initial load
|
// Initial load
|
||||||
loadDashboard();
|
loadDashboard();
|
||||||
refreshInterval = setInterval(() => {
|
startAutoRefresh();
|
||||||
if (document.getElementById('dashboard-tab').classList.contains('active')) {
|
|
||||||
loadDashboard();
|
|
||||||
}
|
|
||||||
}, 30000);
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"version": 0
|
"version": 1
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
#include <Tactility/service/webserver/WebServerService.h>
|
#include <Tactility/service/webserver/WebServerService.h>
|
||||||
#include <Tactility/Assets.h>
|
#include <Tactility/Assets.h>
|
||||||
#include <Tactility/lvgl/Toolbar.h>
|
#include <Tactility/lvgl/Toolbar.h>
|
||||||
|
#include <Tactility/lvgl/LvglSync.h>
|
||||||
#include <Tactility/Logger.h>
|
#include <Tactility/Logger.h>
|
||||||
|
|
||||||
#include <lvgl.h>
|
#include <lvgl.h>
|
||||||
@ -46,7 +47,10 @@ class WebServerSettingsApp final : public App {
|
|||||||
app->wsSettings.wifiMode = static_cast<settings::webserver::WiFiMode>(index);
|
app->wsSettings.wifiMode = static_cast<settings::webserver::WiFiMode>(index);
|
||||||
app->updated = true;
|
app->updated = true;
|
||||||
app->wifiSettingsChanged = true;
|
app->wifiSettingsChanged = true;
|
||||||
app->updateUrlDisplay();
|
if (lvgl::lock(100)) {
|
||||||
|
app->updateUrlDisplay();
|
||||||
|
lvgl::unlock();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,7 +61,10 @@ class WebServerSettingsApp final : public App {
|
|||||||
app->wsSettings.webServerEnabled = enabled;
|
app->wsSettings.webServerEnabled = enabled;
|
||||||
app->updated = true;
|
app->updated = true;
|
||||||
app->webServerEnabledChanged = true;
|
app->webServerEnabledChanged = true;
|
||||||
app->updateUrlDisplay();
|
if (lvgl::lock(100)) {
|
||||||
|
app->updateUrlDisplay();
|
||||||
|
lvgl::unlock();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -128,8 +135,8 @@ class WebServerSettingsApp final : public App {
|
|||||||
auto* btn = static_cast<lv_obj_t*>(lv_event_get_target_obj(e));
|
auto* btn = static_cast<lv_obj_t*>(lv_event_get_target_obj(e));
|
||||||
lv_obj_add_state(btn, LV_STATE_DISABLED);
|
lv_obj_add_state(btn, LV_STATE_DISABLED);
|
||||||
LOGGER.info("Manual asset sync triggered");
|
LOGGER.info("Manual asset sync triggered");
|
||||||
|
|
||||||
getMainDispatcher().dispatch([app, btn]{
|
getMainDispatcher().dispatch([app, btn]{
|
||||||
bool success = service::webserver::syncAssets();
|
bool success = service::webserver::syncAssets();
|
||||||
if (success) {
|
if (success) {
|
||||||
LOGGER.info("Asset sync completed successfully");
|
LOGGER.info("Asset sync completed successfully");
|
||||||
@ -137,8 +144,12 @@ class WebServerSettingsApp final : public App {
|
|||||||
LOGGER.error("Asset sync failed");
|
LOGGER.error("Asset sync failed");
|
||||||
}
|
}
|
||||||
// Only re-enable if button still exists (user hasn't navigated away)
|
// Only re-enable if button still exists (user hasn't navigated away)
|
||||||
if (lv_obj_is_valid(btn)) {
|
// Must acquire LVGL lock since we're not in an LVGL event callback context
|
||||||
lv_obj_remove_state(btn, LV_STATE_DISABLED);
|
if (lvgl::lock(1000)) {
|
||||||
|
if (lv_obj_is_valid(btn)) {
|
||||||
|
lv_obj_remove_state(btn, LV_STATE_DISABLED);
|
||||||
|
}
|
||||||
|
lvgl::unlock();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -277,6 +288,10 @@ public:
|
|||||||
lv_label_set_text(ws_user_label, "Username");
|
lv_label_set_text(ws_user_label, "Username");
|
||||||
lv_obj_align(ws_user_label, LV_ALIGN_LEFT_MID, 0, 0);
|
lv_obj_align(ws_user_label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||||
textAreaWebServerUsername = lv_textarea_create(ws_user_wrapper);
|
textAreaWebServerUsername = lv_textarea_create(ws_user_wrapper);
|
||||||
|
if (!wsSettings.webServerAuthEnabled) {
|
||||||
|
lv_obj_add_state(textAreaWebServerUsername, LV_STATE_DISABLED);
|
||||||
|
lv_obj_remove_flag(textAreaWebServerUsername, LV_OBJ_FLAG_CLICKABLE);
|
||||||
|
}
|
||||||
lv_obj_set_width(textAreaWebServerUsername, 120);
|
lv_obj_set_width(textAreaWebServerUsername, 120);
|
||||||
lv_obj_align(textAreaWebServerUsername, LV_ALIGN_RIGHT_MID, 0, 0);
|
lv_obj_align(textAreaWebServerUsername, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||||
lv_textarea_set_one_line(textAreaWebServerUsername, true);
|
lv_textarea_set_one_line(textAreaWebServerUsername, true);
|
||||||
@ -293,6 +308,10 @@ public:
|
|||||||
lv_label_set_text(ws_pass_label, "Password");
|
lv_label_set_text(ws_pass_label, "Password");
|
||||||
lv_obj_align(ws_pass_label, LV_ALIGN_LEFT_MID, 0, 0);
|
lv_obj_align(ws_pass_label, LV_ALIGN_LEFT_MID, 0, 0);
|
||||||
textAreaWebServerPassword = lv_textarea_create(ws_pass_wrapper);
|
textAreaWebServerPassword = lv_textarea_create(ws_pass_wrapper);
|
||||||
|
if (!wsSettings.webServerAuthEnabled) {
|
||||||
|
lv_obj_add_state(textAreaWebServerPassword, LV_STATE_DISABLED);
|
||||||
|
lv_obj_remove_flag(textAreaWebServerPassword, LV_OBJ_FLAG_CLICKABLE);
|
||||||
|
}
|
||||||
lv_obj_set_width(textAreaWebServerPassword, 120);
|
lv_obj_set_width(textAreaWebServerPassword, 120);
|
||||||
lv_obj_align(textAreaWebServerPassword, LV_ALIGN_RIGHT_MID, 0, 0);
|
lv_obj_align(textAreaWebServerPassword, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||||
lv_textarea_set_one_line(textAreaWebServerPassword, true);
|
lv_textarea_set_one_line(textAreaWebServerPassword, true);
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <format>
|
#include <format>
|
||||||
|
#include <memory>
|
||||||
#include <esp_random.h>
|
#include <esp_random.h>
|
||||||
|
|
||||||
namespace tt::service::webserver {
|
namespace tt::service::webserver {
|
||||||
@ -202,24 +203,37 @@ static bool copyDirectory(const char* src, const char* dst, int depth = 0) {
|
|||||||
|
|
||||||
std::string srcPath = file::getChildPath(src, entry.d_name);
|
std::string srcPath = file::getChildPath(src, entry.d_name);
|
||||||
std::string dstPath = file::getChildPath(dst, entry.d_name);
|
std::string dstPath = file::getChildPath(dst, entry.d_name);
|
||||||
|
|
||||||
if (entry.d_type == file::TT_DT_DIR) {
|
// Determine entry type - use stat() directly for unknown/unexpected d_type values
|
||||||
|
// (FAT/SD card filesystems often return non-standard d_type values)
|
||||||
|
// Note: We use stat() directly here instead of file::isDirectory/isFile to avoid
|
||||||
|
// deadlock, since listDirectory already holds a lock on the parent directory.
|
||||||
|
bool isDir = (entry.d_type == file::TT_DT_DIR);
|
||||||
|
bool isReg = (entry.d_type == file::TT_DT_REG);
|
||||||
|
if (!isDir && !isReg) {
|
||||||
|
struct stat st;
|
||||||
|
if (stat(srcPath.c_str(), &st) == 0) {
|
||||||
|
isDir = S_ISDIR(st.st_mode);
|
||||||
|
isReg = S_ISREG(st.st_mode);
|
||||||
|
} else {
|
||||||
|
LOGGER.warn("Failed to stat entry, skipping: {}", srcPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDir) {
|
||||||
// Recursively copy subdirectory
|
// Recursively copy subdirectory
|
||||||
if (!copyDirectory(srcPath.c_str(), dstPath.c_str(), depth + 1)) {
|
if (!copyDirectory(srcPath.c_str(), dstPath.c_str(), depth + 1)) {
|
||||||
copySuccess = false;
|
copySuccess = false;
|
||||||
}
|
}
|
||||||
} else if (entry.d_type == file::TT_DT_REG) {
|
} else if (isReg) {
|
||||||
// Copy file using atomic temp file approach
|
// Copy file - no additional locking needed since listDirectory already holds a lock
|
||||||
auto lock = file::getLock(srcPath);
|
// and we're the only accessor during sync
|
||||||
lock->lock(portMAX_DELAY);
|
|
||||||
|
|
||||||
// Generate unique temp file path
|
|
||||||
std::string tempPath = std::format("{}.tmp.{}", dstPath, esp_random());
|
std::string tempPath = std::format("{}.tmp.{}", dstPath, esp_random());
|
||||||
|
|
||||||
FILE* srcFile = fopen(srcPath.c_str(), "rb");
|
FILE* srcFile = fopen(srcPath.c_str(), "rb");
|
||||||
if (!srcFile) {
|
if (!srcFile) {
|
||||||
LOGGER.error("Failed to open source file: {}", srcPath);
|
LOGGER.error("Failed to open source file: {}", srcPath);
|
||||||
lock->unlock();
|
|
||||||
copySuccess = false;
|
copySuccess = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -228,17 +242,17 @@ static bool copyDirectory(const char* src, const char* dst, int depth = 0) {
|
|||||||
if (!tempFile) {
|
if (!tempFile) {
|
||||||
LOGGER.error("Failed to create temp file: {}", tempPath);
|
LOGGER.error("Failed to create temp file: {}", tempPath);
|
||||||
fclose(srcFile);
|
fclose(srcFile);
|
||||||
lock->unlock();
|
|
||||||
copySuccess = false;
|
copySuccess = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy in chunks
|
// Copy in chunks (heap-allocated buffer to avoid stack overflow)
|
||||||
char buffer[512];
|
constexpr size_t COPY_BUF_SIZE = 4096;
|
||||||
|
auto buffer = std::make_unique<char[]>(COPY_BUF_SIZE);
|
||||||
size_t bytesRead;
|
size_t bytesRead;
|
||||||
bool fileCopySuccess = true;
|
bool fileCopySuccess = true;
|
||||||
while ((bytesRead = fread(buffer, 1, sizeof(buffer), srcFile)) > 0) {
|
while ((bytesRead = fread(buffer.get(), 1, COPY_BUF_SIZE, srcFile)) > 0) {
|
||||||
size_t bytesWritten = fwrite(buffer, 1, bytesRead, tempFile);
|
size_t bytesWritten = fwrite(buffer.get(), 1, bytesRead, tempFile);
|
||||||
if (bytesWritten != bytesRead) {
|
if (bytesWritten != bytesRead) {
|
||||||
LOGGER.error("Failed to write to temp file: {}", tempPath);
|
LOGGER.error("Failed to write to temp file: {}", tempPath);
|
||||||
fileCopySuccess = false;
|
fileCopySuccess = false;
|
||||||
@ -274,7 +288,9 @@ static bool copyDirectory(const char* src, const char* dst, int depth = 0) {
|
|||||||
fclose(tempFile);
|
fclose(tempFile);
|
||||||
|
|
||||||
if (fileCopySuccess) {
|
if (fileCopySuccess) {
|
||||||
// Atomically rename temp file to destination
|
// Remove destination if it exists (rename may not overwrite on some filesystems)
|
||||||
|
remove(dstPath.c_str());
|
||||||
|
// Rename temp file to destination
|
||||||
if (rename(tempPath.c_str(), dstPath.c_str()) != 0) {
|
if (rename(tempPath.c_str(), dstPath.c_str()) != 0) {
|
||||||
LOGGER.error("Failed to rename temp file {} to {}", tempPath, dstPath);
|
LOGGER.error("Failed to rename temp file {} to {}", tempPath, dstPath);
|
||||||
remove(tempPath.c_str());
|
remove(tempPath.c_str());
|
||||||
@ -286,8 +302,6 @@ static bool copyDirectory(const char* src, const char* dst, int depth = 0) {
|
|||||||
remove(tempPath.c_str());
|
remove(tempPath.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
lock->unlock();
|
|
||||||
|
|
||||||
if (fileCopySuccess) {
|
if (fileCopySuccess) {
|
||||||
LOGGER.info("Copied file: {}", entry.d_name);
|
LOGGER.info("Copied file: {}", entry.d_name);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user