Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion embedded_resources/web_interface/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,35 @@
<div class="dialog-head">Uploading</div>
<div class="dialog-body"></div>
</div>
<div class="dialog zip-extract hidden">
<div class="dialog-head">Zip uploaded</div>
<div class="dialog-body">
<p class="zip-name"></p>
<p class="zip-hint">What would you like to do with this archive?</p>
<div class="zip-actions">
<button class="btn-action act-zip-extract-here">
Extract here
</button>
<button class="btn-action act-zip-extract-to">
Extract to…
</button>
<button class="btn-action act-zip-keep">Just keep the file</button>
</div>
<label class="zip-option">
<input type="checkbox" class="zip-delete-after" />
Delete .zip after extraction
</label>
<div class="zip-folder-picker hidden">
<p>Choose a destination folder:</p>
<div class="zip-folder-list"></div>
</div>
</div>
<div class="dialog-footer">
<button class="btn-action act-dialog-close act-escape">
Cancel
</button>
</div>
</div>
<div class="dialog settings hidden">
<div class="dialog-head">Settings</div>
<div class="dialog-body">
Expand Down Expand Up @@ -697,4 +726,4 @@
<div class="upload-area hidden"></div>
<script src="/index.js"></script>
</body>
</html>
</html>
218 changes: 216 additions & 2 deletions embedded_resources/web_interface/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,179 @@ async function appendDroppedFiles(entry) {
}
});
}

// ============================================================================
// Zip extraction flow
// ============================================================================

// State for the currently-pending zip. Set by the state query in uploadFile()
// when the backend reports isZip=true. Cleared after the user acts.
let _pendingZip = null;
// Callback to resume the upload queue after the zip dialog closes.
let _resumeUpload = null;
// Guards against a second zip action firing while the first is in flight.
let _zipBusy = false;

function _showZipDialog(state, onDone) {
_pendingZip = state;
_resumeUpload = onDone;

let dialog = $(".dialog.zip-extract");
// The temp file keeps the original name, so we just display the basename
// of the temp path.
let displayName = state.tempPath.split("/").pop();
dialog.querySelector(".zip-name").textContent = displayName;
dialog.querySelector(".zip-delete-after").checked = false;
dialog.querySelector(".zip-folder-picker").classList.add("hidden");
dialog.querySelector(".zip-folder-list").innerHTML = "";

Dialog.show("zip-extract");
}

async function _zipExtract(targetFolder) {
if (_zipBusy) return;
_zipBusy = true;

let dialog = $(".dialog.zip-extract");
// Disable all buttons in this dialog so the user can't fire a second
// action while we're waiting for the backend.
dialog.querySelectorAll("button").forEach((b) => (b.disabled = true));

let deleteAfter = dialog.querySelector(".zip-delete-after").checked
? "1"
: "0";

Dialog.loading.show("Extracting...");
try {
let r = await requestPost("/extract", {
zipPath: _pendingZip.tempPath,
targetFolder: targetFolder,
fs: currentDrive,
deleteAfter: deleteAfter,
});
let result = JSON.parse(r);
Dialog.loading.hide();
Dialog.hide();
alert(`Extracted ${result.extracted} file(s) to ${result.target}`);
} catch (e) {
Dialog.loading.hide();
alert("Extraction failed: " + e.message);
} finally {
// Give the device a moment to finish any post-response cleanup before
// we fire another request (fetchSystemInfo, fetchFiles, or the next
// queued upload).
await new Promise((r) => setTimeout(r, 400));
dialog.querySelectorAll("button").forEach((b) => (b.disabled = false));
_zipBusy = false;
_finishZipFlow();
}
}

async function _zipKeep() {
if (_zipBusy) return;
_zipBusy = true;

let dialog = $(".dialog.zip-extract");
dialog.querySelectorAll("button").forEach((b) => (b.disabled = true));

Dialog.loading.show("Moving zip...");
try {
let r = await requestPost("/extract", {
zipPath: _pendingZip.tempPath,
targetFolder: currentPath,
fs: currentDrive,
moveOnly: "1",
});
let result = JSON.parse(r);
Dialog.loading.hide();
Dialog.hide();
alert(`Zip saved to ${result.moved}`);
} catch (e) {
Dialog.loading.hide();
alert("Move failed: " + e.message);
} finally {
await new Promise((r) => setTimeout(r, 400));
dialog.querySelectorAll("button").forEach((b) => (b.disabled = false));
_zipBusy = false;
_finishZipFlow();
}
}

async function _zipShowFolderPicker() {
let dialog = $(".dialog.zip-extract");
let picker = dialog.querySelector(".zip-folder-picker");
let list = dialog.querySelector(".zip-folder-list");
picker.classList.remove("hidden");
list.innerHTML = "Loading...";

try {
let r = await requestGet("/listfiles", {
fs: currentDrive,
folder: currentPath,
});
list.innerHTML = "";

// "Use current folder" as the top option
let useCurrent = document.createElement("button");
useCurrent.className = "btn-action";
useCurrent.textContent = `Use current folder (${currentPath})`;
useCurrent.onclick = () => _zipExtract(currentPath);
list.appendChild(useCurrent);

// List subfolders
r.split("\n").forEach((line) => {
let [type, name, size] = line.split(":");
if (type !== "Fo") return;
let sub =
(currentPath.endsWith("/") ? currentPath : currentPath + "/") + name;
let btn = document.createElement("button");
btn.className = "btn-action";
btn.textContent = "→ " + name;
btn.onclick = () => _zipExtract(sub);
list.appendChild(btn);
});

if (list.children.length === 1) {
// only the "use current folder" button — no subfolders
let empty = document.createElement("p");
empty.textContent = "(no subfolders — using current folder only)";
empty.style.fontStyle = "italic";
list.appendChild(empty);
}
} catch (e) {
list.innerHTML = "Failed to list folders: " + e.message;
}
}

function _finishZipFlow() {
_pendingZip = null;
let cb = _resumeUpload;
_resumeUpload = null;
// Refresh file listing and system info (space may have changed).
// By this point the caller has already waited 400ms, so no additional
// delay is needed here.
fetchSystemInfo();
fetchFiles(currentDrive, currentPath);
// Resume the upload queue
if (cb) cb();
}

// Wire up the zip dialog buttons
document
.querySelector(".dialog.zip-extract .act-zip-extract-here")
.addEventListener("click", () => _zipExtract(currentPath));
document
.querySelector(".dialog.zip-extract .act-zip-extract-to")
.addEventListener("click", () => _zipShowFolderPicker());
document
.querySelector(".dialog.zip-extract .act-zip-keep")
.addEventListener("click", () => _zipKeep());
document
.querySelector(".dialog.zip-extract .act-escape")
.addEventListener("click", () => {
_finishZipFlow();
});

async function uploadFile() {
if (_queueUpload.length === 0) {
_runningUpload = false;
Expand Down Expand Up @@ -265,10 +438,51 @@ async function uploadFile() {
}
};
req.onload = () => {
uploadFile();
if (req.status >= 200 && req.status < 300) {
resolve(req.responseText);
// Don't parse the upload response — ask the backend directly
// what the last upload did. This is a normal GET that goes through
// the same code path as /systeminfo, so it can't be confused by
// multipart handling.
requestGet("/upload_state")
.then((stateRaw) => {
let state;
try {
state = JSON.parse(stateRaw);
} catch (_) {
state = { isZip: false };
}

if (state.isZip) {
// Pause the upload queue and show the dialog. It resumes
// when the zip flow finishes.
resolve(stateRaw);
_runningUpload = false;
_showZipDialog(state, () => {
if (_queueUpload.length > 0) {
uploadFile();
} else {
$(".dialog.upload .dialog-body").innerHTML = "";
Dialog.hide();
fetchSystemInfo();
fetchFiles(currentDrive, currentPath);
}
});
return;
}

// Normal upload — continue queue.
uploadFile();
resolve(stateRaw);
})
.catch((err) => {
// If the state query fails for any reason, treat it as a
// normal upload so we don't wedge the queue.
console.error("upload_state query failed:", err);
uploadFile();
resolve("");
});
} else {
uploadFile();
reject();
}
};
Expand Down
9 changes: 7 additions & 2 deletions platformio.ini
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ build_flags =
-DFASTLED_RMT_BUILTIN_DRIVER=1 ; https://github.com/FastLED/FastLED/blob/67436be11fc3b7be611cdf9011f31c4f76817741/src/platforms/esp/32/rmt_4/idf4_clockless_rmt_esp32.h#L25-L33
; This setting let RF Spectrum to work with LED interface with FastLED on ESP32-S3
-Werror=odr ; Treats One Definition Rule violations as errors in LTO builds
-D CONFIG_ASYNC_TCP_STACK_SIZE=4096 ; Reduces stack size for Async TCP to save memory
-D CONFIG_ASYNC_TCP_STACK_SIZE=20480 ;
-DCONFIG_ASYNC_TCP_RUNNING_CORE=1 ;
-DCONFIG_ASYNC_TCP_USE_WDT=0 ; Disables the Watchdog timer on Async TCP, to avoid restartings during file uploads
-DGEN_MQJS_HEADERS ; Enable generation of mquickjs headers
Expand All @@ -164,6 +164,9 @@ build_flags =
;-DCONFIG_MBEDTLS_SHA512_C=1
;-DCONFIG_MBEDTLS_HKDF_C=1
;-DCONFIG_MBEDTLS_GCM_C=1
; Miniz buffer flags
-D MINIZ_LZ_DICT_SIZE=16384
-D MINIZ_OUT_BUF_SIZE=2048

extra_scripts =
pre:patch.py
Expand Down Expand Up @@ -212,13 +215,14 @@ lib_deps =
bitbank2/AnimatedGIF
bitbank2/PNGdec @ ^1.1.2
ESP32Async/ESPAsyncWebServer
fastled/FastLED @^3.10.3
fastled/FastLED @3.10.3
jgromes/RadioLib @ ^7.4.0
; Pinned to fixed commits so upstream changes never break our patches
; (applied by patch_library_conflicts.py): IRQ-less I2C polling for
; ST25R3916-fork, NFC-A anti-collision timing/retries for NFC-RFAL-fork.
https://github.com/lewisxhe/ST25R3916-fork#0c8e00f49d12881d1cff6d6f0879b90ce7ed4033
https://github.com/lewisxhe/NFC-RFAL-fork#7bde4587ea44c36c178a43d083d7a7345c1e4cfe
https://github.com/lbernstone/miniz-esp32

[env_light]
extends = env
Expand Down Expand Up @@ -282,6 +286,7 @@ lib_deps =
ESP32Async/AsyncTCP@3.4.10
ESP32Async/ESPAsyncWebServer@3.9.6
;https://github.com/bmorcelli/FastLED
https://github.com/lbernstone/miniz-esp32

[env_4mb]
extends = env
Expand Down
Loading
Loading