diff --git a/src/helpers/esp32/MultiTransportCompanionInterface.cpp b/src/helpers/esp32/MultiTransportCompanionInterface.cpp index 89c3cc4..bad5cbe 100644 --- a/src/helpers/esp32/MultiTransportCompanionInterface.cpp +++ b/src/helpers/esp32/MultiTransportCompanionInterface.cpp @@ -2,6 +2,10 @@ #include #include "WifiRuntimeStore.h" // persist BLE on/off (ble_en) across reboots #include "WebMirror.h" // web UI mirror bridge (served over the WS server) +#include "WebFileTransferConfig.h" +#if WADA_WEB_FILE_TRANSFER +#include "WebFileTransfer.h" +#endif #include #include @@ -118,7 +122,12 @@ static void wsMirrorStreamTask(void* arg) { WebSocketCompanionServer* ws = static_cast(arg); for (;;) { ws->serviceMirror(g_web_mirror); - vTaskDelay(pdMS_TO_TICKS(g_web_mirror.clients() > 0 ? 2 : 25)); // fast when a browser is watching, idle otherwise +#if WADA_WEB_FILE_TRANSFER + const bool browser_active = g_web_mirror.clients() > 0 || g_web_file_transfer.clients() > 0; +#else + const bool browser_active = g_web_mirror.clients() > 0; +#endif + vTaskDelay(pdMS_TO_TICKS(browser_active ? 2 : 25)); // fast when a browser is active, idle otherwise } } diff --git a/src/helpers/esp32/WebFileTransfer.cpp b/src/helpers/esp32/WebFileTransfer.cpp new file mode 100644 index 0000000..203cbc7 --- /dev/null +++ b/src/helpers/esp32/WebFileTransfer.cpp @@ -0,0 +1,203 @@ +#include "WebFileTransfer.h" + +#if WADA_WEB_FILE_TRANSFER + +#include +#include +#include +#include + +WebFileTransfer g_web_file_transfer; + +bool WebFileTransfer::begin() { + if (!_inbound) { + const size_t bytes = INBOUND_SLOTS * MAX_INBOUND_BYTES; + _inbound = static_cast( + heap_caps_malloc(bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + if (!_inbound) _inbound = static_cast(malloc(bytes)); + } + if (!_outbound) { + _outbound = static_cast( + heap_caps_malloc(MAX_OUTBOUND_BYTES, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + if (!_outbound) _outbound = static_cast(malloc(MAX_OUTBOUND_BYTES)); + } + return _inbound != nullptr && _outbound != nullptr; +} + +bool WebFileTransfer::setEnabled(bool enabled, uint32_t code) { + if (!enabled) { + _enabled = false; + __sync_synchronize(); + _code = 0; + _clients = 0; + _auth_failures = 0; + _auth_blocked_until_ms = 0; + discardTraffic(); + return true; + } + if (!begin() || code < 100000 || code > 999999) return false; + discardTraffic(); + _code = code; + _clients = 0; + _auth_failures = 0; + _auth_blocked_until_ms = 0; + _last_activity_ms = millis(); + __sync_synchronize(); + _enabled = true; + return true; +} + +bool WebFileTransfer::matchesCode(const uint8_t* text, size_t len) const { + if (!_enabled || !text || len != 6) return false; + uint32_t value = 0; + for (size_t i = 0; i < len; ++i) { + if (text[i] < '0' || text[i] > '9') return false; + value = value * 10u + static_cast(text[i] - '0'); + } + return value == _code; +} + +bool WebFileTransfer::authAllowed() const { + return _auth_blocked_until_ms == 0 || + static_cast(millis() - _auth_blocked_until_ms) >= 0; +} + +void WebFileTransfer::noteAuthFailure() { + if (++_auth_failures >= 3) { + _auth_failures = 0; + _auth_blocked_until_ms = millis() + 30000u; + } +} + +void WebFileTransfer::noteAuthSuccess() { + _auth_failures = 0; + _auth_blocked_until_ms = 0; + touch(); +} + +void WebFileTransfer::touch() { + _last_activity_ms = millis(); +} + +bool WebFileTransfer::pushInbound(uint8_t opcode, const uint8_t* data, size_t len) { + if (!_enabled || !_inbound || !data || len == 0 || len > MAX_INBOUND_BYTES) return false; + portENTER_CRITICAL(&_inbound_mux); + const uint8_t next = static_cast((_inbound_head + 1) % INBOUND_SLOTS); + if (next == _inbound_tail) { + portEXIT_CRITICAL(&_inbound_mux); + return false; + } + const uint8_t slot = _inbound_head; + memcpy(_inbound + static_cast(slot) * MAX_INBOUND_BYTES, data, len); + _inbound_len[slot] = static_cast(len); + _inbound_opcode[slot] = opcode; + _inbound_head = next; + portEXIT_CRITICAL(&_inbound_mux); + touch(); + return true; +} + +size_t WebFileTransfer::popInbound(uint8_t* opcode, uint8_t* dst, size_t max_len) { + portENTER_CRITICAL(&_inbound_mux); + const uint8_t head = _inbound_head; + if (_inbound_tail == head) { + portEXIT_CRITICAL(&_inbound_mux); + return 0; + } + const uint8_t slot = _inbound_tail; + const size_t len = _inbound_len[slot]; + if (!dst || len == 0 || len > max_len) { + _inbound_tail = static_cast((slot + 1) % INBOUND_SLOTS); + portEXIT_CRITICAL(&_inbound_mux); + return 0; + } + memcpy(dst, _inbound + static_cast(slot) * MAX_INBOUND_BYTES, len); + if (opcode) *opcode = _inbound_opcode[slot]; + _inbound_tail = static_cast((slot + 1) % INBOUND_SLOTS); + portEXIT_CRITICAL(&_inbound_mux); + return len; +} + +bool WebFileTransfer::pushReply(const char* text) { + if (!_enabled || !text) return false; + const size_t len = strlen(text); + if (len == 0 || len >= MAX_REPLY_BYTES) return false; + portENTER_CRITICAL(&_reply_mux); + const uint8_t next = static_cast((_reply_head + 1) % REPLY_SLOTS); + if (next == _reply_tail) { + portEXIT_CRITICAL(&_reply_mux); + return false; + } + const uint8_t slot = _reply_head; + memcpy(_reply[slot], text, len + 1); + _reply_len[slot] = static_cast(len); + _reply_head = next; + portEXIT_CRITICAL(&_reply_mux); + touch(); + return true; +} + +size_t WebFileTransfer::popReply(uint8_t* dst, size_t max_len) { + portENTER_CRITICAL(&_reply_mux); + const uint8_t head = _reply_head; + if (_reply_tail == head) { + portEXIT_CRITICAL(&_reply_mux); + return 0; + } + const uint8_t slot = _reply_tail; + const size_t len = _reply_len[slot]; + if (!dst || len == 0 || len > max_len) { + portEXIT_CRITICAL(&_reply_mux); + return 0; + } + memcpy(dst, _reply[slot], len); + _reply_tail = static_cast((slot + 1) % REPLY_SLOTS); + portEXIT_CRITICAL(&_reply_mux); + return len; +} + +bool WebFileTransfer::pushData(const uint8_t* data, size_t len) { + if (!_enabled || !_outbound || !data || len == 0 || len > MAX_OUTBOUND_BYTES) return false; + portENTER_CRITICAL(&_outbound_mux); + if (_outbound_len != 0) { + portEXIT_CRITICAL(&_outbound_mux); + return false; + } + memcpy(_outbound, data, len); + _outbound_len = static_cast(len); + portEXIT_CRITICAL(&_outbound_mux); + touch(); + return true; +} + +size_t WebFileTransfer::popData(uint8_t* dst, size_t max_len) { + if (!dst) return 0; + portENTER_CRITICAL(&_outbound_mux); + const size_t len = _outbound_len; + if (len == 0 || len > max_len) { + portEXIT_CRITICAL(&_outbound_mux); + return 0; + } + memcpy(dst, _outbound, len); + _outbound_len = 0; + portEXIT_CRITICAL(&_outbound_mux); + return len; +} + +void WebFileTransfer::clearData() { + portENTER_CRITICAL(&_outbound_mux); + _outbound_len = 0; + portEXIT_CRITICAL(&_outbound_mux); +} + +void WebFileTransfer::discardTraffic() { + portENTER_CRITICAL(&_inbound_mux); + _inbound_tail = _inbound_head; + portEXIT_CRITICAL(&_inbound_mux); + portENTER_CRITICAL(&_reply_mux); + _reply_tail = _reply_head; + portEXIT_CRITICAL(&_reply_mux); + clearData(); +} + +#endif // WADA_WEB_FILE_TRANSFER \ No newline at end of file diff --git a/src/helpers/esp32/WebFileTransfer.h b/src/helpers/esp32/WebFileTransfer.h new file mode 100644 index 0000000..5408fc9 --- /dev/null +++ b/src/helpers/esp32/WebFileTransfer.h @@ -0,0 +1,71 @@ +#pragma once + +#include +#include +#include "WebFileTransferConfig.h" + +#if WADA_WEB_FILE_TRANSFER +#include + +class WebFileTransfer { +public: + static constexpr size_t MAX_INBOUND_BYTES = 2052; // LE32 offset + a 2 KiB chunk + static constexpr size_t MAX_REPLY_BYTES = 160; + static constexpr size_t MAX_OUTBOUND_BYTES = 2053; // type + LE32 offset + 2 KiB + + bool begin(); + bool setEnabled(bool enabled, uint32_t code = 0); + bool enabled() const { return _enabled; } + uint32_t code() const { return _code; } + bool matchesCode(const uint8_t* text, size_t len) const; + bool authAllowed() const; + void noteAuthFailure(); + void noteAuthSuccess(); + + bool pushInbound(uint8_t opcode, const uint8_t* data, size_t len); + size_t popInbound(uint8_t* opcode, uint8_t* dst, size_t max_len); + + bool pushReply(const char* text); + size_t popReply(uint8_t* dst, size_t max_len); + bool pushData(const uint8_t* data, size_t len); + size_t popData(uint8_t* dst, size_t max_len); + void clearData(); + void discardTraffic(); + + void noteClients(int count) { _clients = count; } + int clients() const { return _clients; } + void touch(); + uint32_t lastActivityMs() const { return _last_activity_ms; } + +private: + static constexpr uint8_t INBOUND_SLOTS = 3; + static constexpr uint8_t REPLY_SLOTS = 8; + + uint8_t* _inbound = nullptr; + uint16_t _inbound_len[INBOUND_SLOTS] = {0}; + uint8_t _inbound_opcode[INBOUND_SLOTS] = {0}; + volatile uint8_t _inbound_head = 0; + volatile uint8_t _inbound_tail = 0; + portMUX_TYPE _inbound_mux = portMUX_INITIALIZER_UNLOCKED; + + char _reply[REPLY_SLOTS][MAX_REPLY_BYTES] = {{0}}; + uint8_t _reply_len[REPLY_SLOTS] = {0}; + volatile uint8_t _reply_head = 0; + volatile uint8_t _reply_tail = 0; + portMUX_TYPE _reply_mux = portMUX_INITIALIZER_UNLOCKED; + + uint8_t* _outbound = nullptr; + volatile uint16_t _outbound_len = 0; + portMUX_TYPE _outbound_mux = portMUX_INITIALIZER_UNLOCKED; + + volatile bool _enabled = false; + volatile uint32_t _code = 0; + volatile int _clients = 0; + volatile uint32_t _last_activity_ms = 0; + volatile uint32_t _auth_blocked_until_ms = 0; + volatile uint8_t _auth_failures = 0; +}; + +extern WebFileTransfer g_web_file_transfer; + +#endif // WADA_WEB_FILE_TRANSFER \ No newline at end of file diff --git a/src/helpers/esp32/WebFileTransferConfig.h b/src/helpers/esp32/WebFileTransferConfig.h new file mode 100644 index 0000000..f94f18d --- /dev/null +++ b/src/helpers/esp32/WebFileTransferConfig.h @@ -0,0 +1,28 @@ +#pragma once + +// Browser file transfer requires the shared WebSocket transport and a removable +// card backend. Keep internal SPIFFS/LittleFS-only boards out: /transfer can hold +// large uploads and is intended to remain user-removable storage. +#if defined(MULTI_TRANSPORT_COMPANION) && \ + (defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) || \ + defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) || \ + defined(HAS_WIO_TRACKER_L2) || defined(HAS_TDISPLAY_P4) || \ + defined(HAS_TANMATSU)) + #define WADA_WEB_FILE_TRANSFER 1 +#else + #define WADA_WEB_FILE_TRANSFER 0 +#endif + +#if WADA_WEB_FILE_TRANSFER && \ + (defined(HAS_WIO_TRACKER_L2) || defined(HAS_TDISPLAY_P4) || \ + defined(HAS_TANMATSU)) + #define WADA_WEB_FILE_TRANSFER_SDMMC 1 +#else + #define WADA_WEB_FILE_TRANSFER_SDMMC 0 +#endif + +#if WADA_WEB_FILE_TRANSFER && !WADA_WEB_FILE_TRANSFER_SDMMC + #define WADA_WEB_FILE_TRANSFER_SPI_SD 1 +#else + #define WADA_WEB_FILE_TRANSFER_SPI_SD 0 +#endif \ No newline at end of file diff --git a/src/helpers/esp32/WebFileTransferPage.h b/src/helpers/esp32/WebFileTransferPage.h new file mode 100644 index 0000000..5a6e23d --- /dev/null +++ b/src/helpers/esp32/WebFileTransferPage.h @@ -0,0 +1,81 @@ +#pragma once + +#include "WebFileTransferConfig.h" + +#if WADA_WEB_FILE_TRANSFER + +static const char WS_HTTP_FILES_PAGE[] = + "HTTP/1.1 200 OK\r\n" + "Content-Type: text/html; charset=utf-8\r\n" + "Cache-Control: no-store\r\n" + "Content-Security-Policy: default-src 'self'; connect-src 'self' ws: wss:; style-src 'unsafe-inline'; script-src 'unsafe-inline'\r\n" + "Connection: close\r\n" + "\r\n" +R"FILEPAGE( + + + + +WADAMESH File Transfer + + +
+
WADAMESH FILE TRANSFER
+
+ + + +
+
+ + +
No file selected
+ +
+
Enter the session code to connect.
+ Files are uploaded to the device's SD card in /transfer. The transfer session ends when you leave the File Transfer app on the device. +
+
+
Device files
+
Connect to list screenshots and transferred files.
+
+
+)FILEPAGE"; + +static const char WS_HTTP_FILES_DISABLED[] = + "HTTP/1.1 503 Service Unavailable\r\n" + "Content-Type: text/plain; charset=utf-8\r\n" + "Cache-Control: no-store\r\n" + "Connection: close\r\n\r\n" + "File Transfer is not enabled on the device.\n"; + +#endif // WADA_WEB_FILE_TRANSFER \ No newline at end of file diff --git a/src/helpers/esp32/WebFileTransferProtocol.h b/src/helpers/esp32/WebFileTransferProtocol.h new file mode 100644 index 0000000..a097340 --- /dev/null +++ b/src/helpers/esp32/WebFileTransferProtocol.h @@ -0,0 +1,63 @@ +#pragma once + +#include +#include +#include + +namespace WebFileTransferProtocol { + +inline uint32_t crc32Update(uint32_t crc, const uint8_t* data, size_t len) { + for (size_t i = 0; i < len; ++i) { + crc ^= data[i]; + for (int bit = 0; bit < 8; ++bit) + crc = (crc >> 1) ^ (0xEDB88320u & (0u - (crc & 1u))); + } + return crc; +} + +inline uint32_t readLe32(const uint8_t* data) { + return static_cast(data[0]) | + (static_cast(data[1]) << 8) | + (static_cast(data[2]) << 16) | + (static_cast(data[3]) << 24); +} + +inline bool parseHex32(const char* text, uint32_t* value) { + if (!text || !value || strlen(text) != 8) return false; + uint32_t out = 0; + for (int i = 0; i < 8; ++i) { + const char c = text[i]; + uint8_t nibble; + if (c >= '0' && c <= '9') nibble = static_cast(c - '0'); + else if (c >= 'a' && c <= 'f') nibble = static_cast(c - 'a' + 10); + else if (c >= 'A' && c <= 'F') nibble = static_cast(c - 'A' + 10); + else return false; + out = (out << 4) | nibble; + } + *value = out; + return true; +} + +inline bool fileNameValid(const char* name) { + if (!name || !name[0] || name[0] == '.' || strlen(name) > 64) return false; + const size_t len = strlen(name); + if (len >= 5 && strcmp(name + len - 5, ".part") == 0) return false; + for (size_t i = 0; i < len; ++i) { + const char c = name[i]; + const bool valid = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-'; + if (!valid) return false; + } + return true; +} + +inline bool readablePath(const char* path) { + if (!path) return false; + const char* leaf = nullptr; + if (strncmp(path, "/screenshots/", 13) == 0) leaf = path + 13; + else if (strncmp(path, "/transfer/", 10) == 0) leaf = path + 10; + else return false; + return fileNameValid(leaf) && strchr(leaf, '/') == nullptr; +} + +} // namespace WebFileTransferProtocol \ No newline at end of file diff --git a/src/helpers/esp32/WebSocketCompanionServer.cpp b/src/helpers/esp32/WebSocketCompanionServer.cpp index 5160325..34af3a4 100644 --- a/src/helpers/esp32/WebSocketCompanionServer.cpp +++ b/src/helpers/esp32/WebSocketCompanionServer.cpp @@ -5,6 +5,10 @@ #include #include #include "WebMirror.h" +#if WADA_WEB_FILE_TRANSFER +#include "WebFileTransfer.h" +#include "WebFileTransferPage.h" +#endif #ifndef WS_FRAME_DEBUG #define WS_FRAME_DEBUG 0 @@ -520,7 +524,17 @@ WebSocketCompanionServer::WebSocketCompanionServer() _clients[i].stall_ms = 0; _clients[i].is_mirror = false; _clients[i].is_term = false; + _clients[i].is_files = false; _clients[i].meta_sent = false; + #if WADA_WEB_FILE_TRANSFER + _clients[i].files_authed = false; + _clients[i].files_close_after_tx = false; + _clients[i].files_request_pending = false; + _clients[i].files_frame_started_ms = 0; + _clients[i].files_auth_failures = 0; + _clients[i].files_rx_buf = nullptr; + _clients[i].files_rx_len = 0; + #endif _clients[i].tx_buf = nullptr; _clients[i].tx_len = 0; _clients[i].tx_sent = 0; @@ -575,6 +589,13 @@ void WebSocketCompanionServer::adoptClient(WiFiClient& incoming) { // reports true). Without this, every new connect is accept()ed then // immediately stop()ed, producing SYN/SYN+ACK/ACK/FIN-ACK with zero data. if (slot < 0) { +#if WADA_WEB_FILE_TRANSFER + // The third slot is reserved capacity for the runtime file-transfer client. + // Never let a new browser connection evict a live companion/VNC/terminal + // client before its HTTP path is known; a full server simply refuses it. + incoming.stop(); + return; +#else uint32_t now = millis(); uint32_t oldest_age = 0; for (int i = 0; i < WS_COMPANION_MAX_CLIENTS; i++) { @@ -586,6 +607,7 @@ void WebSocketCompanionServer::adoptClient(WiFiClient& incoming) { } _clients[slot].client.stop(); _clients[slot].in_use = false; + #endif } _clients[slot].client = incoming; _clients[slot].in_use = true; @@ -597,7 +619,16 @@ void WebSocketCompanionServer::adoptClient(WiFiClient& incoming) { _clients[slot].stall_ms = 0; _clients[slot].is_mirror = false; _clients[slot].is_term = false; + _clients[slot].is_files = false; _clients[slot].meta_sent = false; +#if WADA_WEB_FILE_TRANSFER + _clients[slot].files_authed = false; + _clients[slot].files_close_after_tx = false; + _clients[slot].files_request_pending = false; + _clients[slot].files_frame_started_ms = 0; + _clients[slot].files_auth_failures = 0; + _clients[slot].files_rx_len = 0; +#endif _clients[slot].tx_len = 0; // drop any stale pending frame from the previous occupant (tx_buf is reused) _clients[slot].tx_sent = 0; } @@ -647,6 +678,28 @@ bool WebSocketCompanionServer::doHandshake(int idx) { size_t key_len = key_end - key_start; if (key_len == 0 || key_len > 128) break; + // Route browser endpoints before accepting the upgrade so an unavailable + // file-transfer session never observes a successful WebSocket handshake. + c->is_mirror = (strncmp(c->handshake_buf, "GET /mirror ", 12) == 0); + c->is_term = (strncmp(c->handshake_buf, "GET /term ", 10) == 0); +#if WADA_WEB_FILE_TRANSFER + c->is_files = (strncmp(c->handshake_buf, "GET /files ", 11) == 0); + bool another_files = false; + if (c->is_files) { + for (int j = 0; j < WS_COMPANION_MAX_CLIENTS; ++j) + if (j != idx && _clients[j].in_use && _clients[j].is_files) another_files = true; + } + if (c->is_files && (!g_web_file_transfer.enabled() || another_files)) { + const char* unavailable = "HTTP/1.1 503 Service Unavailable\r\n" + "Content-Length: 0\r\nConnection: close\r\n\r\n"; + writeAllBytes(*cl, reinterpret_cast(unavailable), + strlen(unavailable), TCP_WRITE_TIMEOUT_MS); + c->client.stop(); + c->in_use = false; + return false; + } +#endif + char concat[128 + sizeof(WS_MAGIC)]; memcpy(concat, key_start, key_len); memcpy(concat + key_len, WS_MAGIC, sizeof(WS_MAGIC) - 1); @@ -668,12 +721,7 @@ bool WebSocketCompanionServer::doHandshake(int idx) { if (!writeAllBytes(*cl, (const uint8_t*)"\r\n\r\n", 4, TCP_WRITE_TIMEOUT_MS)) return false; - // Route GET /mirror to the web-UI mirror channel (display out + pointer - // in); every other WS upgrade (the companion app connects to "/") stays - // a companion peer on the shared protocol. - c->is_mirror = (strncmp(c->handshake_buf, "GET /mirror", 11) == 0); - c->is_term = (strncmp(c->handshake_buf, "GET /term", 9) == 0); // web mesh terminal socket - if (c->is_mirror || c->is_term) c->client.setNoDelay(true); // low latency: small frames, no Nagle coalescing + if (c->is_mirror || c->is_term || c->is_files) c->client.setNoDelay(true); // low latency: small frames, no Nagle coalescing c->meta_sent = false; c->handshake_done = true; c->ws_state = WS_STATE_HEADER_0; @@ -681,8 +729,15 @@ bool WebSocketCompanionServer::doHandshake(int idx) { return true; } } - // Plain HTTP GET (no WS upgrade): serve the terminal page in terminal mode, else the mirror page. - const char* page = g_web_mirror.terminalOn() ? WS_HTML_TERMINAL_PAGE : WS_HTTP_INFO_PAGE; + // Plain HTTP GET (no WS upgrade): /files is a runtime-gated transfer page; + // the root keeps serving terminal or mirror according to its existing mode. + const char* page; + #if WADA_WEB_FILE_TRANSFER + if (strncmp(c->handshake_buf, "GET /files ", 11) == 0) + page = g_web_file_transfer.enabled() ? WS_HTTP_FILES_PAGE : WS_HTTP_FILES_DISABLED; + else + #endif + page = g_web_mirror.terminalOn() ? WS_HTML_TERMINAL_PAGE : WS_HTTP_INFO_PAGE; (void)writeAllBytes(*cl, (const uint8_t*)page, strlen(page), TCP_WRITE_TIMEOUT_MS); c->client.stop(); c->in_use = false; @@ -722,7 +777,7 @@ size_t WebSocketCompanionServer::pollRecvFrame(uint8_t dest[], int* client_index } // Mirror clients carry framebuffer/pointer traffic, not companion frames — // serviceMirror() owns their socket. Never feed their bytes to the parser. - if (c->is_mirror || c->is_term) continue; + if (c->is_mirror || c->is_term || c->is_files) continue; WiFiClient* cl = &c->client; while (cl->available()) { @@ -860,7 +915,7 @@ size_t WebSocketCompanionServer::writeToAllClients(const uint8_t src[], size_t l int connected = 0; int sent = 0; for (int i = 0; i < WS_COMPANION_MAX_CLIENTS; i++) { - bool ok = _clients[i].in_use && _clients[i].client.connected() && _clients[i].handshake_done && !_clients[i].is_mirror && !_clients[i].is_term; + bool ok = _clients[i].in_use && _clients[i].client.connected() && _clients[i].handshake_done && !_clients[i].is_mirror && !_clients[i].is_term && !_clients[i].is_files; if (ok) { connected++; if (writeToClient(i, src, len) == len) sent++; @@ -873,14 +928,14 @@ bool WebSocketCompanionServer::isClientConnected(int client_index) const { WsClientsLock _lk(_client_mtx); if (client_index < 0 || client_index >= WS_COMPANION_MAX_CLIENTS) return false; const WSClientState* c = &_clients[client_index]; - return c->in_use && c->client.connected() && c->handshake_done && !c->is_mirror && !c->is_term; + return c->in_use && c->client.connected() && c->handshake_done && !c->is_mirror && !c->is_term && !c->is_files; } int WebSocketCompanionServer::connectedCount() const { WsClientsLock _lk(_client_mtx); int n = 0; for (int i = 0; i < WS_COMPANION_MAX_CLIENTS; i++) { - if (_clients[i].in_use && _clients[i].client.connected() && _clients[i].handshake_done && !_clients[i].is_mirror && !_clients[i].is_term) + if (_clients[i].in_use && _clients[i].client.connected() && _clients[i].handshake_done && !_clients[i].is_mirror && !_clients[i].is_term && !_clients[i].is_files) n++; } return n; @@ -1094,6 +1149,206 @@ void WebSocketCompanionServer::drainTermInput(int idx, WebMirror& m) { } } +#if WADA_WEB_FILE_TRANSFER +bool WebSocketCompanionServer::sendFileReply(int idx, const char* text) { + if (!text || !text[0]) return false; + const size_t len = strlen(text); + return writeBinaryFrame(idx, reinterpret_cast(text), len) == len; +} + +// Parse one file-transfer socket. Browser frames are intentionally stop-and-wait: +// BEGIN -> READY, each binary chunk -> ACK, END -> DONE. That makes the bounded +// bridge backpressure explicit and prevents the network task from outrunning SD. +void WebSocketCompanionServer::drainFileInput(int idx) { + static constexpr uint32_t kFrameTimeoutMs = 5000; + WSClientState* c = &_clients[idx]; + WiFiClient* cl = &c->client; + if (c->ws_state != WS_STATE_HEADER_0 && + millis() - c->files_frame_started_ms > kFrameTimeoutMs) { + disconnectClient(idx); + return; + } + if (!c->files_rx_buf) { + c->files_rx_buf = static_cast(heap_caps_malloc( + WebFileTransfer::MAX_INBOUND_BYTES, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + if (!c->files_rx_buf) + c->files_rx_buf = static_cast(malloc(WebFileTransfer::MAX_INBOUND_BYTES)); + if (!c->files_rx_buf) { + c->client.stop(); + c->in_use = false; + return; + } + } + + int guard = 0; + while (cl->available() && guard++ < 4096) { + switch (c->ws_state) { + case WS_STATE_HEADER_0: { + const uint8_t b = static_cast(cl->read()); + c->files_frame_started_ms = millis(); + const bool final_frame = (b & 0x80) != 0; + const bool reserved_bits = (b & 0x70) != 0; + c->ws_opcode = b & 0x0F; + if (!final_frame || reserved_bits || + (c->ws_opcode != 0x01 && c->ws_opcode != 0x02 && c->ws_opcode != 0x08)) { + disconnectClient(idx); + return; + } + c->ws_state = WS_STATE_HEADER_1; + break; + } + case WS_STATE_HEADER_1: { + const uint8_t b = static_cast(cl->read()); + if ((b & 0x80) == 0) { // RFC 6455: every client-to-server frame is masked + disconnectClient(idx); + return; + } + const uint8_t len7 = b & 0x7F; + if (c->ws_opcode == 0x08 && len7 >= 126) { + disconnectClient(idx); + return; + } + c->ws_payload_read = 0; + c->files_rx_len = 0; + if (len7 == 127) { + disconnectClient(idx); + return; + } + if (len7 == 126) { + c->ws_payload_len = len7; + c->ws_state = WS_STATE_LEN_EXT; + } else { + c->ws_payload_len = len7; + c->ws_state = WS_STATE_MASK; + } + break; + } + case WS_STATE_LEN_EXT: { + if (c->ws_payload_len == 126) { + if (cl->available() < 2) return; + const uint8_t hi = static_cast(cl->read()); + const uint8_t lo = static_cast(cl->read()); + c->ws_payload_len = (static_cast(hi) << 8) | lo; + } + if (c->ws_payload_len < 126 || + c->ws_payload_len > WebFileTransfer::MAX_INBOUND_BYTES) { + disconnectClient(idx); + return; + } + c->ws_state = WS_STATE_MASK; + break; + } + case WS_STATE_MASK: { + if (cl->available() < 4) return; + for (int i = 0; i < 4; ++i) c->ws_mask[i] = static_cast(cl->read()); + if (c->ws_payload_len == 0) { + c->files_frame_started_ms = 0; + if (c->ws_opcode == 0x08) { + disconnectClient(idx); + return; + } + c->ws_state = WS_STATE_HEADER_0; + } else { + c->ws_state = WS_STATE_PAYLOAD; + } + break; + } + default: { + const uint8_t value = static_cast(cl->read()) ^ + c->ws_mask[c->ws_payload_read % 4]; + if (c->files_rx_len < WebFileTransfer::MAX_INBOUND_BYTES) + c->files_rx_buf[c->files_rx_len++] = value; + c->ws_payload_read++; + if (c->ws_payload_read < c->ws_payload_len) break; + + if (c->ws_opcode == 0x08) { + disconnectClient(idx); + return; + } + if (c->ws_payload_len > WebFileTransfer::MAX_INBOUND_BYTES) { + if (c->tx_len == 0) sendFileReply(idx, "ERR frame too large"); + } else if (!c->files_authed) { + const bool allowed = g_web_file_transfer.authAllowed(); + const bool auth = allowed && c->ws_opcode == 0x01 && c->files_rx_len == 11 && + memcmp(c->files_rx_buf, "AUTH ", 5) == 0 && + g_web_file_transfer.matchesCode(c->files_rx_buf + 5, 6); + if (auth) { + c->files_authed = true; + c->files_auth_failures = 0; + g_web_file_transfer.noteAuthSuccess(); + if (c->tx_len == 0) sendFileReply(idx, "AUTH OK"); + } else { + c->files_auth_failures++; + if (allowed) g_web_file_transfer.noteAuthFailure(); + if (c->tx_len == 0) + sendFileReply(idx, allowed ? "ERR invalid code" : "ERR try again in 30 seconds"); + if (c->files_auth_failures >= 3) c->files_close_after_tx = true; + } + } else if ((c->ws_opcode == 0x01 || c->ws_opcode == 0x02) && + c->files_rx_len > 0) { + if (g_web_file_transfer.pushInbound(c->ws_opcode, c->files_rx_buf, + c->files_rx_len)) { + c->files_request_pending = true; + } else if (c->tx_len == 0) { + sendFileReply(idx, "ERR device busy"); + } + } + c->ws_state = WS_STATE_HEADER_0; + c->files_frame_started_ms = 0; + return; // one complete application frame per service pass + } + } + } +} + +void WebSocketCompanionServer::serviceFileClients() { + int clients = 0; + int active = -1; + for (int i = 0; i < WS_COMPANION_MAX_CLIENTS; ++i) { + WSClientState* c = &_clients[i]; + if (!c->in_use || !c->client.connected() || !c->handshake_done || !c->is_files) continue; + clients++; + active = i; + drainClientTx(i); + if (!c->in_use) continue; + if (!g_web_file_transfer.enabled()) { + if (c->tx_len == 0) sendFileReply(i, "ERR session ended"); + c->files_close_after_tx = true; + } else if (!c->files_request_pending && c->tx_len == 0) { + drainFileInput(i); + } + if (c->in_use && c->files_close_after_tx && c->tx_len == 0) disconnectClient(i); + } + g_web_file_transfer.noteClients(clients); + + if (active < 0 || !_clients[active].in_use || !_clients[active].files_authed || + _clients[active].tx_len != 0) return; + uint8_t reply[WebFileTransfer::MAX_REPLY_BYTES]; + const size_t len = g_web_file_transfer.popReply(reply, sizeof reply); + if (len > 0) { + if (writeBinaryFrame(active, reply, len) == len) { + _clients[active].files_request_pending = false; + } else { + char retry[WebFileTransfer::MAX_REPLY_BYTES]; + memcpy(retry, reply, len); + retry[len] = '\0'; + g_web_file_transfer.pushReply(retry); + } + return; + } + if (!_mirror_txbuf) return; + const size_t data_len = g_web_file_transfer.popData( + _mirror_txbuf, WebFileTransfer::MAX_OUTBOUND_BYTES); + if (data_len > 0) { + if (writeBinaryFrame(active, _mirror_txbuf, data_len) == data_len) { + _clients[active].files_request_pending = false; + } else { + g_web_file_transfer.pushData(_mirror_txbuf, data_len); + } + } +} +#endif + // Web mesh terminal: shuttle text both ways for /term clients (independent of the // framebuffer mirror; a client is never both). Called from serviceMirror on the stream // task with _client_mtx already held. writeBinaryFrame's per-client tx_buf keeps it @@ -1135,6 +1390,9 @@ void WebSocketCompanionServer::serviceMirror(WebMirror& m) { _mirror_txbuf = (uint8_t*)heap_caps_malloc(WS_MIRROR_TXBUF, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if (!_mirror_txbuf) _mirror_txbuf = (uint8_t*)malloc(WS_MIRROR_TXBUF); } +#if WADA_WEB_FILE_TRANSFER + serviceFileClients(); // authenticated browser upload channel +#endif serviceTerminalClients(m); // web mesh terminal + Contacts/Chats data (independent of the framebuffer) const int mc = mirrorClientCount(); m.noteClients(mc); diff --git a/src/helpers/esp32/WebSocketCompanionServer.h b/src/helpers/esp32/WebSocketCompanionServer.h index efc624f..ce56761 100644 --- a/src/helpers/esp32/WebSocketCompanionServer.h +++ b/src/helpers/esp32/WebSocketCompanionServer.h @@ -4,6 +4,7 @@ #include #include #include +#include "WebFileTransferConfig.h" #if defined(HAS_TDISPLAY_P4) // Same type rebind as TCPCompanionServer.h (every TU must see one consistent class layout). @@ -19,7 +20,11 @@ class WebMirror; // web-UI mirror bridge (WebMirror.h); included in the .cpp #ifndef WS_COMPANION_MAX_CLIENTS -#define WS_COMPANION_MAX_CLIENTS 2 + #if WADA_WEB_FILE_TRANSFER + #define WS_COMPANION_MAX_CLIENTS 3 + #else + #define WS_COMPANION_MAX_CLIENTS 2 + #endif #endif #ifndef WS_HANDSHAKE_MAX_LEN @@ -54,7 +59,17 @@ struct WSClientState { bool is_mirror; // this client is a web-UI mirror (GET /mirror), not a companion peer bool is_term; // this client is a web mesh terminal (GET /term) + bool is_files; // authenticated browser file transfer (GET /files) bool meta_sent; // mirror: the one-time screen-size meta frame has been sent +#if WADA_WEB_FILE_TRANSFER + bool files_authed; + bool files_close_after_tx; + bool files_request_pending; + uint32_t files_frame_started_ms; + uint8_t files_auth_failures; + uint8_t* files_rx_buf; + uint16_t files_rx_len; +#endif // Mirror TX buffer: one WS frame (header + payload) queued for this client, drained // NON-BLOCKING across loop iterations. The loop never spins on a socket write and a @@ -117,6 +132,11 @@ private: void drainClientTx(int idx); // push a mirror client's pending tx_buf bytes, non-blocking void serviceTerminalClients(WebMirror& m); // web mesh terminal: reply text out + command text in void drainTermInput(int idx, WebMirror& m); // parse a term client's WS frames -> m.pushTermCmd +#if WADA_WEB_FILE_TRANSFER + void serviceFileClients(); + void drainFileInput(int idx); + bool sendFileReply(int idx, const char* text); +#endif // The _clients array is now touched by TWO cores: the main loop (accept/handshake + // companion RX/TX, core 1) and the dedicated mirror stream task (serviceMirror, core 0). diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 24b0f59..777c007 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -203,6 +203,11 @@ static_assert(ChannelSenderSplit::kMaxWireName >= (size_t)UITask::MAX_SENDER_NAM static_assert(TOUCH_IGNORED_NAME_LEN >= UITask::MAX_SENDER_NAME + 1, "block-list slot must hold a full sender name, or blocking silently stops working"); #include "../helpers/esp32/WebMirror.h" // web UI mirror (framebuffer + pointer bridge) + #include "../helpers/esp32/WebFileTransferConfig.h" + #if WADA_WEB_FILE_TRANSFER + #include "../helpers/esp32/WebFileTransfer.h" + #include "../helpers/esp32/WebFileTransferProtocol.h" + #endif #include "../helpers/ClockFloorRTC.h" extern ClockFloorRTC rtc_clock; // the board clock (variants/*/target.cpp); its send-timestamp floor is seeded/persisted from here (#89) #if defined(MULTI_TRANSPORT_COMPANION) @@ -5384,6 +5389,7 @@ enum { APPHIDE_REMOTE = 1u << 6, APPHIDE_READER = 1u << 7, APPHIDE_TERMINAL = 1u << 8, APPHIDE_FILES = 1u << 9, APPHIDE_SIGNAL = 1u << 10, APPHIDE_MENTIONS = 1u << 11, APPHIDE_MQTT = 1u << 12, // Settings -> MQTT bridge (hidden by default: experimental + privacy) + APPHIDE_FILE_TRANSFER = 1u << 13, }; #endif @@ -24489,6 +24495,604 @@ static void spectrumDismissCb(lv_event_t* e) { closeSpectrumPage(); } +#if WADA_WEB_FILE_TRANSFER +// ============================================================================ +// File Transfer app: authenticated browser access to removable SD storage. +// Network code only queues complete frames; this UI-loop owner performs every +// filesystem operation so the core-0 WebSocket task never calls into FAT/SD. +// ============================================================================ +static lv_obj_t* s_file_transfer_root = nullptr; +static lv_obj_t* s_file_transfer_status = nullptr; +static lv_timer_t* s_file_transfer_timer = nullptr; +static bool s_file_transfer_started_tcp = false; +static File s_file_transfer_file; +static File s_file_transfer_read_file; +static File s_file_transfer_list_dir; +static bool s_file_transfer_uploading = false; +static bool s_file_transfer_downloading = false; +static bool s_file_transfer_listing = false; +static uint8_t s_file_transfer_list_phase = 0; +static uint32_t s_file_transfer_expected = 0; +static uint32_t s_file_transfer_received = 0; +static uint32_t s_file_transfer_crc = 0xFFFFFFFFu; +static uint32_t s_file_transfer_download_size = 0; +static uint32_t s_file_transfer_download_offset = 0; +static char s_file_transfer_name[65] = {0}; +static char s_file_transfer_final[96] = {0}; +static char s_file_transfer_result[112] = {0}; +static constexpr const char* kFileTransferTemp = "/transfer/.upload.part"; +static constexpr uint32_t kFileTransferMaxBytes = 512u * 1024u * 1024u; +static constexpr uint32_t kFileTransferIdleMs = 10u * 60u * 1000u; +static constexpr uint32_t kFileTransferChunkTimeoutMs = 30000u; +static void closeFileTransferPage(); + +static fs::FS& fileTransferStorage() { +#if WADA_WEB_FILE_TRANSFER_SDMMC + return SD_MMC; +#else + return SD; +#endif +} + +static bool fileTransferStorageMounted() { +#if WADA_WEB_FILE_TRANSFER_SDMMC + return SD_MMC.cardType() != CARD_NONE; +#else + return s_sd_mounted && SD.cardType() != CARD_NONE; +#endif +} + +static bool fileTransferStorageReady() { +#if WADA_WEB_FILE_TRANSFER_SDMMC + return tanSdTryMount() && SD_MMC.cardType() != CARD_NONE; +#else + return fmSdTryMount() && s_sd_mounted && SD.cardType() != CARD_NONE; +#endif +} + +static void fileTransferStorageIoFailed() { +#if WADA_WEB_FILE_TRANSFER_SPI_SD + sdNoteIoFailure(); +#else + s_tan_sd_mounted = false; + s_tan_sd_retry_after = millis() + 8000; +#endif +} + +static void fileTransferResetUpload(bool remove_temp) { + fs::FS& storage = fileTransferStorage(); + if (s_file_transfer_file) { + s_file_transfer_file.close(); + markSdIo(); + } + if (remove_temp && fileTransferStorageMounted() && storage.exists(kFileTransferTemp)) { + if (!storage.remove(kFileTransferTemp)) fileTransferStorageIoFailed(); + markSdIo(); + } + s_file_transfer_uploading = false; + s_file_transfer_expected = 0; + s_file_transfer_received = 0; + s_file_transfer_crc = 0xFFFFFFFFu; + s_file_transfer_name[0] = '\0'; + s_file_transfer_final[0] = '\0'; +} + +static void fileTransferResetRead(bool clear_queued_data = true) { + if (s_file_transfer_read_file) { + s_file_transfer_read_file.close(); + markSdIo(); + } + if (s_file_transfer_list_dir) s_file_transfer_list_dir.close(); + s_file_transfer_downloading = false; + s_file_transfer_listing = false; + s_file_transfer_list_phase = 0; + s_file_transfer_download_size = 0; + s_file_transfer_download_offset = 0; + if (clear_queued_data) g_web_file_transfer.clearData(); +} + +static void fileTransferReply(const char* text) { + if (text) g_web_file_transfer.pushReply(text); +} + +static void fileTransferFail(const char* text) { + fileTransferResetRead(); + fileTransferResetUpload(true); + snprintf(s_file_transfer_result, sizeof s_file_transfer_result, "%s", text ? text : "Upload failed"); + char reply[WebFileTransfer::MAX_REPLY_BYTES]; + snprintf(reply, sizeof reply, "ERR %s", s_file_transfer_result); + fileTransferReply(reply); +} + +static void fileTransferBegin(const char* command) { + unsigned long declared = 0; + char name[65] = {0}; + char extra = 0; + if (!command || sscanf(command, "BEGIN %lu %64s %c", &declared, name, &extra) != 2 || + declared == 0 || declared > kFileTransferMaxBytes || + !WebFileTransferProtocol::fileNameValid(name)) { + fileTransferFail("invalid file name or size"); + return; + } + if (!fileTransferStorageReady()) { + fileTransferFail("SD card unavailable"); + return; + } + + fs::FS& storage = fileTransferStorage(); + fileTransferResetRead(); + fileTransferResetUpload(true); + if (storage.exists(kFileTransferTemp)) { + fileTransferFail("cannot clear temporary file"); + return; + } + if (!storage.exists("/transfer") && !storage.mkdir("/transfer")) { + fileTransferStorageIoFailed(); + fileTransferFail("cannot create transfer directory"); + return; + } + snprintf(s_file_transfer_final, sizeof s_file_transfer_final, "/transfer/%s", name); + if (storage.exists(s_file_transfer_final)) { + fileTransferFail("file already exists"); + return; + } + s_file_transfer_file = storage.open(kFileTransferTemp, FILE_WRITE); + markSdIo(); + if (!s_file_transfer_file) { + fileTransferStorageIoFailed(); + fileTransferFail("cannot create temporary file"); + return; + } + snprintf(s_file_transfer_name, sizeof s_file_transfer_name, "%s", name); + s_file_transfer_expected = static_cast(declared); + s_file_transfer_received = 0; + s_file_transfer_crc = 0xFFFFFFFFu; + s_file_transfer_result[0] = '\0'; + s_file_transfer_uploading = true; + fileTransferReply("READY"); +} + +static void fileTransferChunk(const uint8_t* frame, size_t len) { + if (!s_file_transfer_uploading || !s_file_transfer_file) { + fileTransferFail("no upload in progress"); + return; + } + if (!frame || len <= 4 || len > WebFileTransfer::MAX_INBOUND_BYTES) { + fileTransferFail("invalid chunk"); + return; + } + const uint32_t offset = WebFileTransferProtocol::readLe32(frame); + const size_t payload_len = len - 4; + if (offset != s_file_transfer_received || + static_cast(s_file_transfer_received) + payload_len > s_file_transfer_expected) { + fileTransferFail("unexpected chunk offset"); + return; + } + if (s_file_transfer_file.write(frame + 4, payload_len) != payload_len) { + fileTransferStorageIoFailed(); + fileTransferFail("write failed (card full?)"); + return; + } + markSdIo(); + s_file_transfer_crc = WebFileTransferProtocol::crc32Update( + s_file_transfer_crc, frame + 4, payload_len); + s_file_transfer_received += static_cast(payload_len); + char reply[32]; + snprintf(reply, sizeof reply, "ACK %lu", static_cast(s_file_transfer_received)); + fileTransferReply(reply); +} + +static void fileTransferEnd(const char* command) { + uint32_t browser_crc = 0; + if (!s_file_transfer_uploading || !s_file_transfer_file || !command || + strncmp(command, "END ", 4) != 0 || + !WebFileTransferProtocol::parseHex32(command + 4, &browser_crc)) { + fileTransferFail("invalid upload completion"); + return; + } + const uint32_t actual_crc = s_file_transfer_crc ^ 0xFFFFFFFFu; + if (s_file_transfer_received != s_file_transfer_expected || actual_crc != browser_crc) { + fileTransferFail("size or checksum mismatch"); + return; + } + + s_file_transfer_file.flush(); + s_file_transfer_file.close(); + markSdIo(); + fs::FS& storage = fileTransferStorage(); + if (!storage.rename(kFileTransferTemp, s_file_transfer_final)) { + fileTransferStorageIoFailed(); + if (!storage.remove(kFileTransferTemp)) fileTransferStorageIoFailed(); + fileTransferResetUpload(false); + snprintf(s_file_transfer_result, sizeof s_file_transfer_result, "%s", "Could not commit upload"); + fileTransferReply("ERR could not commit upload"); + return; + } + markSdIo(); + const uint32_t completed = s_file_transfer_received; + char completed_name[sizeof s_file_transfer_name]; + snprintf(completed_name, sizeof completed_name, "%s", s_file_transfer_name); + fileTransferResetUpload(false); + snprintf(s_file_transfer_result, sizeof s_file_transfer_result, + "Saved %s (%lu bytes)", completed_name, static_cast(completed)); + char reply[48]; + snprintf(reply, sizeof reply, "DONE %lu", static_cast(completed)); + fileTransferReply(reply); +} + +static void fileTransferListNext() { + fs::FS& storage = fileTransferStorage(); + while (s_file_transfer_listing) { + if (!s_file_transfer_list_dir) { + const char* dir_path = s_file_transfer_list_phase == 0 ? "/screenshots" : "/transfer"; + s_file_transfer_list_dir = storage.open(dir_path, FILE_READ); + markSdIo(); + if (!s_file_transfer_list_dir || !s_file_transfer_list_dir.isDirectory()) { + if (s_file_transfer_list_dir) s_file_transfer_list_dir.close(); + if (s_file_transfer_list_phase == 1) { + fileTransferStorageIoFailed(); + s_file_transfer_listing = false; + fileTransferReply("ERR SD read failed"); + return; + } + s_file_transfer_list_phase++; + if (s_file_transfer_list_phase >= 2) { + s_file_transfer_listing = false; + fileTransferReply("LIST DONE"); + } + continue; + } + } + + File entry = s_file_transfer_list_dir.openNextFile(); + if (!entry) { + s_file_transfer_list_dir.close(); + s_file_transfer_list_phase++; + if (s_file_transfer_list_phase >= 2) { + s_file_transfer_listing = false; + fileTransferReply("LIST DONE"); + } + continue; + } + const bool directory = entry.isDirectory(); + const uint32_t size = static_cast(entry.size()); + const char* raw_name = entry.name(); + const char* leaf = raw_name ? strrchr(raw_name, '/') : nullptr; + leaf = leaf ? leaf + 1 : raw_name; + char safe_leaf[65] = {0}; + const bool name_fits = leaf && strlen(leaf) < sizeof safe_leaf; + if (name_fits) snprintf(safe_leaf, sizeof safe_leaf, "%s", leaf); + entry.close(); + if (directory || !name_fits || !WebFileTransferProtocol::fileNameValid(safe_leaf)) continue; + + const char* prefix = s_file_transfer_list_phase == 0 ? "/screenshots/" : "/transfer/"; + char reply[WebFileTransfer::MAX_REPLY_BYTES]; + snprintf(reply, sizeof reply, "ENTRY %lu %s%s", static_cast(size), + prefix, safe_leaf); + fileTransferReply(reply); + return; + } +} + +static void fileTransferListBegin() { + if (s_file_transfer_uploading) { + fileTransferReply("ERR upload in progress"); + return; + } + if (!fileTransferStorageReady()) { + fileTransferStorageIoFailed(); + fileTransferReply("ERR SD card unavailable"); + return; + } + fileTransferResetRead(); + s_file_transfer_result[0] = '\0'; + s_file_transfer_listing = true; + fileTransferListNext(); +} + +static void fileTransferDownloadBegin(const char* path) { + if (s_file_transfer_uploading) { + fileTransferReply("ERR upload in progress"); + return; + } + if (!WebFileTransferProtocol::readablePath(path)) { + fileTransferReply("ERR invalid download path"); + return; + } + if (!fileTransferStorageReady()) { + fileTransferStorageIoFailed(); + fileTransferReply("ERR SD card unavailable"); + return; + } + fileTransferResetRead(); + s_file_transfer_read_file = fileTransferStorage().open(path, FILE_READ); + markSdIo(); + if (!s_file_transfer_read_file || s_file_transfer_read_file.isDirectory()) { + fileTransferStorageIoFailed(); + fileTransferResetRead(); + fileTransferReply("ERR file unavailable"); + return; + } + const uint64_t size = s_file_transfer_read_file.size(); + if (size == 0 || size > kFileTransferMaxBytes) { + fileTransferResetRead(); + fileTransferReply("ERR file size unsupported"); + return; + } + s_file_transfer_downloading = true; + s_file_transfer_download_size = static_cast(size); + s_file_transfer_download_offset = 0; + s_file_transfer_result[0] = '\0'; + const char* leaf = strrchr(path, '/'); + leaf = leaf ? leaf + 1 : path; + snprintf(s_file_transfer_name, sizeof s_file_transfer_name, "%s", leaf); + char reply[WebFileTransfer::MAX_REPLY_BYTES]; + snprintf(reply, sizeof reply, "FILE %lu %s", static_cast(size), leaf); + fileTransferReply(reply); +} + +static void fileTransferDownloadRead(const char* command) { + unsigned long requested = 0; + char extra = 0; + if (!s_file_transfer_downloading || !s_file_transfer_read_file || !command || + sscanf(command, "READ %lu %c", &requested, &extra) != 1 || + requested != s_file_transfer_download_offset) { + fileTransferResetRead(); + fileTransferReply("ERR invalid download offset"); + return; + } + static uint8_t outbound[WebFileTransfer::MAX_OUTBOUND_BYTES]; + const size_t remaining = s_file_transfer_download_size - s_file_transfer_download_offset; + const size_t wanted = remaining > 2048 ? 2048 : remaining; + const size_t got = s_file_transfer_read_file.read(outbound + 5, wanted); + markSdIo(); + if (got != wanted) { + fileTransferStorageIoFailed(); + fileTransferResetRead(); + fileTransferReply("ERR download read failed"); + return; + } + outbound[0] = 0x01; + const uint32_t offset = s_file_transfer_download_offset; + outbound[1] = static_cast(offset); + outbound[2] = static_cast(offset >> 8); + outbound[3] = static_cast(offset >> 16); + outbound[4] = static_cast(offset >> 24); + if (!g_web_file_transfer.pushData(outbound, got + 5)) { + if (!s_file_transfer_read_file.seek(offset)) fileTransferStorageIoFailed(); + fileTransferReply("ERR device busy"); + return; + } + s_file_transfer_download_offset += static_cast(got); + if (s_file_transfer_download_offset >= s_file_transfer_download_size) { + const uint32_t completed = s_file_transfer_download_size; + char completed_name[sizeof s_file_transfer_name]; + snprintf(completed_name, sizeof completed_name, "%s", s_file_transfer_name); + fileTransferResetRead(false); // final chunk is queued for the network task + snprintf(s_file_transfer_result, sizeof s_file_transfer_result, + "Sent %s (%lu bytes)", completed_name, static_cast(completed)); + } +} + +static bool webFileTransferStorageBusy() { + return s_file_transfer_uploading || s_file_transfer_downloading || s_file_transfer_listing || + static_cast(s_file_transfer_file) || + static_cast(s_file_transfer_read_file) || + static_cast(s_file_transfer_list_dir); +} + +static void webFileTransferTick() { + if (!g_web_file_transfer.enabled()) return; + if (WiFi.status() != WL_CONNECTED || static_cast(WiFi.localIP()) == 0) { + if (g_lv.task) g_lv.task->showAlert(TR("File Transfer stopped: Wi-Fi disconnected"), 2000); + closeFileTransferPage(); + return; + } + if (g_web_file_transfer.clients() == 0) { + if (s_file_transfer_uploading || s_file_transfer_downloading || s_file_transfer_listing) { + fileTransferResetUpload(true); + fileTransferResetRead(); + snprintf(s_file_transfer_result, sizeof s_file_transfer_result, "%s", + "Transfer cancelled: browser disconnected"); + } + g_web_file_transfer.discardTraffic(); + if (millis() - g_web_file_transfer.lastActivityMs() > kFileTransferIdleMs) { + if (g_lv.task) g_lv.task->showAlert(TR("File Transfer session expired"), 1800); + closeFileTransferPage(); + } + return; + } + static uint8_t frame[WebFileTransfer::MAX_INBOUND_BYTES]; + uint8_t opcode = 0; + int budget = 3; + while (budget-- > 0) { + const size_t len = g_web_file_transfer.popInbound(&opcode, frame, sizeof frame); + if (len == 0) break; + if (opcode == 0x02) { + fileTransferChunk(frame, len); + continue; + } + if (len >= sizeof frame) { + fileTransferFail("command too long"); + continue; + } + frame[len] = 0; + const char* command = reinterpret_cast(frame); + if (strncmp(command, "BEGIN ", 6) == 0) fileTransferBegin(command); + else if (strncmp(command, "END ", 4) == 0) fileTransferEnd(command); + else if (strcmp(command, "LIST") == 0) fileTransferListBegin(); + else if (strcmp(command, "LIST NEXT") == 0) fileTransferListNext(); + else if (strncmp(command, "GET ", 4) == 0) fileTransferDownloadBegin(command + 4); + else if (strncmp(command, "READ ", 5) == 0) fileTransferDownloadRead(command); + else if (strcmp(command, "CANCEL") == 0) { + fileTransferResetUpload(true); + fileTransferResetRead(); + snprintf(s_file_transfer_result, sizeof s_file_transfer_result, "%s", "Upload cancelled"); + fileTransferReply("CANCELLED"); + } else { + fileTransferFail("unknown command"); + } + } + + const uint32_t inactive_ms = millis() - g_web_file_transfer.lastActivityMs(); + if ((s_file_transfer_uploading || s_file_transfer_downloading || s_file_transfer_listing) && + inactive_ms > kFileTransferChunkTimeoutMs) { + fileTransferResetUpload(true); + fileTransferResetRead(); + snprintf(s_file_transfer_result, sizeof s_file_transfer_result, "%s", "Transfer timed out"); + fileTransferReply("ERR transfer timed out"); + } + if (!s_file_transfer_uploading && !s_file_transfer_downloading && !s_file_transfer_listing && + inactive_ms > kFileTransferIdleMs) { + if (g_lv.task) g_lv.task->showAlert(TR("File Transfer session expired"), 1800); + closeFileTransferPage(); + } +} + +static void fileTransferRefresh() { + if (!s_file_transfer_status) return; + if (s_file_transfer_uploading) { + lv_label_set_text_fmt(s_file_transfer_status, "Receiving %s\n%lu / %lu bytes", + s_file_transfer_name, + static_cast(s_file_transfer_received), + static_cast(s_file_transfer_expected)); + } else if (s_file_transfer_downloading) { + lv_label_set_text_fmt(s_file_transfer_status, "Sending %s\n%lu / %lu bytes", + s_file_transfer_name, + static_cast(s_file_transfer_download_offset), + static_cast(s_file_transfer_download_size)); + } else if (s_file_transfer_listing) { + lv_label_set_text(s_file_transfer_status, TR("Reading file list")); + } else if (s_file_transfer_result[0]) { + lv_label_set_text(s_file_transfer_status, s_file_transfer_result); + } else if (g_web_file_transfer.clients() > 0) { + lv_label_set_text(s_file_transfer_status, TR("Browser connected")); + } else { + lv_label_set_text(s_file_transfer_status, TR("Waiting for browser")); + } +} + +static void closeFileTransferPage() { + if (s_file_transfer_timer) { lv_timer_del(s_file_transfer_timer); s_file_transfer_timer = nullptr; } + g_web_file_transfer.setEnabled(false); + fileTransferResetUpload(true); + fileTransferResetRead(); + if (s_file_transfer_started_tcp && g_lv.task) g_lv.task->disableTcp(); + s_file_transfer_started_tcp = false; + if (s_file_transfer_root) { popupClose(&s_file_transfer_root); } + s_file_transfer_status = nullptr; + appPageEnd(&closeFileTransferPage); +} + +static void fileTransferStopCb(lv_event_t* e) { + if (lv_event_get_code(e) == LV_EVENT_CLICKED) closeFileTransferPage(); +} + +static void fileTransferTimerCb(lv_timer_t*) { + fileTransferRefresh(); +} + +static void openFileTransferPage() { + closeFileTransferPage(); + if (WiFi.status() != WL_CONNECTED || static_cast(WiFi.localIP()) == 0) { + if (g_lv.task) g_lv.task->showAlert(TR("Connect to Wi-Fi first"), 1800); + return; + } + if (!fileTransferStorageReady()) { + if (g_lv.task) g_lv.task->showAlert(TR("File Transfer needs the SD card"), 2000); + return; + } + fs::FS& storage = fileTransferStorage(); + if (!storage.exists("/transfer") && !storage.mkdir("/transfer")) { + fileTransferStorageIoFailed(); + if (g_lv.task) g_lv.task->showAlert(TR("Could not create transfer folder"), 2000); + return; + } + if (storage.exists(kFileTransferTemp) && !storage.remove(kFileTransferTemp)) { + fileTransferStorageIoFailed(); + if (g_lv.task) g_lv.task->showAlert(TR("Could not clear previous upload"), 2000); + return; + } + markSdIo(); + if (g_lv.task && !g_lv.task->isTcpEnabled()) { + g_lv.task->enableTcp(); + s_file_transfer_started_tcp = true; + } + const uint32_t code = 100000u + esp_random() % 900000u; + s_file_transfer_result[0] = '\0'; + if (!g_web_file_transfer.setEnabled(true, code)) { + if (s_file_transfer_started_tcp && g_lv.task) g_lv.task->disableTcp(); + s_file_transfer_started_tcp = false; + if (g_lv.task) g_lv.task->showAlert(TR("File Transfer: low memory"), 1800); + return; + } + + const lv_coord_t sw = lv_disp_get_hor_res(nullptr); + s_file_transfer_root = appPageCreateRoot(COLOR_BG); + lv_obj_set_style_pad_top(s_file_transfer_root, STATUSBAR_H + 10, LV_PART_MAIN); + lv_obj_set_style_pad_left(s_file_transfer_root, 14, LV_PART_MAIN); + lv_obj_set_style_pad_right(s_file_transfer_root, 14, LV_PART_MAIN); + lv_obj_set_style_pad_bottom(s_file_transfer_root, 14, LV_PART_MAIN); + lv_obj_add_flag(s_file_transfer_root, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_scroll_dir(s_file_transfer_root, LV_DIR_VER); + lv_obj_set_scrollbar_mode(s_file_transfer_root, LV_SCROLLBAR_MODE_ON); + lv_obj_set_style_bg_color(s_file_transfer_root, lv_color_hex(COLOR_ACCENT), LV_PART_SCROLLBAR); + lv_obj_set_style_bg_opa(s_file_transfer_root, LV_OPA_80, LV_PART_SCROLLBAR); + lv_obj_set_style_width(s_file_transfer_root, 5, LV_PART_SCROLLBAR); + lv_obj_set_flex_flow(s_file_transfer_root, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(s_file_transfer_root, LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + lv_obj_set_style_pad_row(s_file_transfer_root, 10, LV_PART_MAIN); + appPageBegin("File Transfer", &closeFileTransferPage); + + const lv_coord_t width = sw - 28; + lv_obj_t* intro = lv_label_create(s_file_transfer_root); + lv_label_set_text(intro, TR("Upload files or download screenshots from a browser on the same Wi-Fi.")); + lv_obj_set_style_text_font(intro, &g_font_14, LV_PART_MAIN); + lv_obj_set_style_text_color(intro, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); + lv_label_set_long_mode(intro, LV_LABEL_LONG_WRAP); + lv_obj_set_width(intro, width); + + lv_obj_t* url = lv_label_create(s_file_transfer_root); + char url_text[72]; + snprintf(url_text, sizeof url_text, "http://%s:" WEB_UI_PORT_STR "/files", + WiFi.localIP().toString().c_str()); + lv_label_set_text(url, url_text); + lv_obj_set_style_text_font(url, &g_font_14, LV_PART_MAIN); + lv_obj_set_style_text_color(url, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); + lv_label_set_long_mode(url, LV_LABEL_LONG_WRAP); + lv_obj_set_width(url, width); + + lv_obj_t* code_caption = lv_label_create(s_file_transfer_root); + lv_label_set_text(code_caption, TR("Session code")); + lv_obj_set_style_text_font(code_caption, &g_font_12, LV_PART_MAIN); + lv_obj_set_style_text_color(code_caption, lv_color_hex(COLOR_SUB), LV_PART_MAIN); + + lv_obj_t* code_label = lv_label_create(s_file_transfer_root); + lv_label_set_text_fmt(code_label, "%06lu", static_cast(code)); + lv_obj_set_style_text_font(code_label, &lv_font_montserrat_28, LV_PART_MAIN); + lv_obj_set_style_text_color(code_label, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); + + s_file_transfer_status = lv_label_create(s_file_transfer_root); + lv_obj_set_style_text_font(s_file_transfer_status, &g_font_12, LV_PART_MAIN); + lv_obj_set_style_text_color(s_file_transfer_status, lv_color_hex(COLOR_SUB), LV_PART_MAIN); + lv_label_set_long_mode(s_file_transfer_status, LV_LABEL_LONG_WRAP); + lv_obj_set_width(s_file_transfer_status, width); + + lv_obj_t* stop = lv_btn_create(s_file_transfer_root); + lv_obj_set_size(stop, width, SC(40)); + styleButton(stop); + lv_obj_add_event_cb(stop, fileTransferStopCb, LV_EVENT_CLICKED, nullptr); + lv_obj_t* stop_label = lv_label_create(stop); + lv_label_set_text(stop_label, TR("Stop File Transfer")); + lv_obj_set_style_text_font(stop_label, &g_font_14, LV_PART_MAIN); + lv_obj_center(stop_label); + + fileTransferRefresh(); + s_file_transfer_timer = lv_timer_create(fileTransferTimerCb, 500, nullptr); +} +#endif + #if !defined(HAS_TANMATSU) // ============================================================================ // VNC app (app-drawer tile -> APPACT_VNC): serve the live UI to a phone browser @@ -42348,6 +42952,9 @@ static void luaStoreRebuildList() { #if CAP_FILESYSTEM || defined(TLORA_PAGER) { "Files", APPHIDE_FILES }, #endif +#if WADA_WEB_FILE_TRANSFER + { "Transfer", APPHIDE_FILE_TRANSFER }, +#endif #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) { "MQTT bridge", APPHIDE_MQTT }, // a Settings section, not a drawer tile #endif @@ -42864,7 +43471,7 @@ static void luaStoreOpenLanguages() { enum AppDrawerAction { APPACT_CHATS, APPACT_CONTACTS, APPACT_MAP, APPACT_SETTINGS, APPACT_ADVERT, APPACT_POWER, APPACT_MENTIONS, APPACT_CMDCENTER, APPACT_SIGNAL, - APPACT_TERMINAL, APPACT_FILES, APPACT_SPECTRUM, APPACT_SNAKE, APPACT_VNC, APPACT_REMOTE, APPACT_READER, + APPACT_TERMINAL, APPACT_FILES, APPACT_FILE_TRANSFER, APPACT_SPECTRUM, APPACT_SNAKE, APPACT_VNC, APPACT_REMOTE, APPACT_READER, APPACT_DISCOVER, APPACT_STORE, APPACT_LUA_BASE = 100, // APPACT_LUA_BASE + i = installed Lua app s_lua_inst[i] }; @@ -43150,6 +43757,9 @@ static void appTileCb(lv_event_t* e) { #endif #if CAP_FILESYSTEM || defined(TLORA_PAGER) case APPACT_FILES: homeFilesCb(e); return; +#endif +#if WADA_WEB_FILE_TRANSFER + case APPACT_FILE_TRANSFER: openFileTransferPage(); return; #endif default: break; } @@ -43195,6 +43805,7 @@ static uint32_t appHideBitFor(int act) { case APPACT_READER: return APPHIDE_READER; case APPACT_TERMINAL: return APPHIDE_TERMINAL; case APPACT_FILES: return APPHIDE_FILES; + case APPACT_FILE_TRANSFER: return APPHIDE_FILE_TRANSFER; case APPACT_SIGNAL: return APPHIDE_SIGNAL; case APPACT_MENTIONS: return APPHIDE_MENTIONS; default: return 0; // core tiles (Chats/Map/Settings/...) stay put @@ -43593,6 +44204,9 @@ static void openAppDrawer() { #if CAP_FILESYSTEM || defined(TLORA_PAGER) { LV_SYMBOL_DIRECTORY, "Files", APPACT_FILES, 0, 0xE6BE4A }, // folder gold #endif +#if WADA_WEB_FILE_TRANSFER + { LV_SYMBOL_UPLOAD, "Transfer", APPACT_FILE_TRANSFER, 0, 0x2FB8A6 }, // authenticated browser upload +#endif #if !CAP_LUA_APPS { nullptr, "Snake", APPACT_SNAKE, 0, 0x53C06B }, // native snake (Lua boards install it from the Store) #else @@ -43614,6 +44228,7 @@ static void openAppDrawer() { case APPACT_READER: return hide_mask & APPHIDE_READER; case APPACT_TERMINAL: return hide_mask & APPHIDE_TERMINAL; case APPACT_FILES: return hide_mask & APPHIDE_FILES; + case APPACT_FILE_TRANSFER: return hide_mask & APPHIDE_FILE_TRANSFER; case APPACT_SIGNAL: return hide_mask & APPHIDE_SIGNAL; case APPACT_MENTIONS: return hide_mask & APPHIDE_MENTIONS; default: return false; @@ -55450,6 +56065,9 @@ static bool sdRuntimeLifecycleBusy() { bool busy = s_hist_flush_busy || s_hist_flush_req || s_sdinfo_request || s_sdinfo_busy || touchPrefsIoBusy(); +#if WADA_WEB_FILE_TRANSFER_SPI_SD + busy = busy || webFileTransferStorageBusy(); +#endif #if defined(MULTI_TRANSPORT_COMPANION) // The web reader and the Lua audio player are independent SD consumers, so // both gate the mount lifecycle. The reader excludes ITSELF: it calls this @@ -55848,6 +56466,9 @@ void UITask::loop() { } } #endif +#if WADA_WEB_FILE_TRANSFER + webFileTransferTick(); +#endif #if !defined(HAS_TANMATSU) // REMOTE mode: draw/refresh the physical-panel placeholder (first pass via the IP // sentinel, then whenever the IP changes), and clear the bootloop guard once this @@ -57199,6 +57820,9 @@ static const PopupEnt k_popup_registry[] = { { P_OPEN(s_vnc_root), []{ closeVncPage(); }, PF_COUNT }, { P_OPEN(s_remote_root), []{ closeRemotePage(); }, PF_COUNT }, #endif +#if WADA_WEB_FILE_TRANSFER + { P_OPEN(s_file_transfer_root), []{ closeFileTransferPage(); }, PF_COUNT }, +#endif #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) { P_OPEN(s_wifi_sheet), []{ wifiSheetClose(); }, PF_COUNT }, // was in no registry at all { P_OPEN(s_reader_root), []{ closeReaderPage(); }, PF_COUNT }, // on-device text browser diff --git a/test/test_web_file_transfer_protocol.cpp b/test/test_web_file_transfer_protocol.cpp new file mode 100644 index 0000000..25d2a69 --- /dev/null +++ b/test/test_web_file_transfer_protocol.cpp @@ -0,0 +1,58 @@ +#include +#include +#include + +#include "helpers/esp32/WebFileTransferProtocol.h" + +#ifndef EXPECT_FILE_TRANSFER +#define EXPECT_FILE_TRANSFER 0 +#endif + +#ifndef EXPECT_FILE_TRANSFER_SDMMC +#define EXPECT_FILE_TRANSFER_SDMMC 0 +#endif + +#include "helpers/esp32/WebFileTransferConfig.h" + +static_assert(WADA_WEB_FILE_TRANSFER == EXPECT_FILE_TRANSFER, + "unexpected file-transfer capability"); +static_assert(WADA_WEB_FILE_TRANSFER_SDMMC == EXPECT_FILE_TRANSFER_SDMMC, + "unexpected file-transfer storage backend"); + +int main() { + using namespace WebFileTransferProtocol; + + const uint8_t crc_input[] = "123456789"; + uint32_t crc = crc32Update(0xFFFFFFFFu, crc_input, strlen((const char*)crc_input)); + assert((crc ^ 0xFFFFFFFFu) == 0xCBF43926u); + + const uint8_t offset[] = {0x78, 0x56, 0x34, 0x12}; + assert(readLe32(offset) == 0x12345678u); + + uint32_t parsed = 0; + assert(parseHex32("cBf43926", &parsed)); + assert(parsed == 0xCBF43926u); + assert(!parseHex32("CBF4392", &parsed)); + assert(!parseHex32("CBF4392Z", &parsed)); + + assert(fileNameValid("photo-01.png")); + assert(fileNameValid("firmware_backup.bin")); + assert(!fileNameValid("")); + assert(!fileNameValid(".hidden")); + assert(!fileNameValid("pending.part")); + assert(!fileNameValid("nested/file.txt")); + assert(!fileNameValid("space name.txt")); + + char long_name[66]; + memset(long_name, 'a', sizeof(long_name)); + long_name[65] = '\0'; + assert(!fileNameValid(long_name)); + + assert(readablePath("/screenshots/capture.png")); + assert(readablePath("/transfer/archive.bin")); + assert(!readablePath("/transfer/.upload.part")); + assert(!readablePath("/transfer/../secret.txt")); + assert(!readablePath("/transfer/nested/file.txt")); + assert(!readablePath("/other/archive.bin")); + return 0; +} \ No newline at end of file