diff --git a/examples/companion_radio/ui-new/Settingsscreen.h b/examples/companion_radio/ui-new/Settingsscreen.h index d5a808b..6947d4c 100644 --- a/examples/companion_radio/ui-new/Settingsscreen.h +++ b/examples/companion_radio/ui-new/Settingsscreen.h @@ -28,6 +28,7 @@ #include #endif #include + #include #include #include #endif @@ -297,6 +298,7 @@ private: // File manager state FmPhase _fmPhase; const char* _fmError; + DNSServer* _dnsServer; #endif // --------------------------------------------------------------------------- @@ -416,12 +418,12 @@ private: // Folder rows for sub-screens addRow(ROW_CONTACTS_SUBMENU); addRow(ROW_CHANNELS_SUBMENU); - - // Info section (stays at top level) - addRow(ROW_INFO_HEADER); #ifdef MECK_OTA_UPDATE addRow(ROW_OTA_TOOLS_SUBMENU); #endif + + // Info section (stays at top level) + addRow(ROW_INFO_HEADER); addRow(ROW_PUB_KEY); addRow(ROW_FIRMWARE); @@ -580,6 +582,7 @@ public: _otaError = nullptr; _fmPhase = FM_PHASE_CONFIRM; _fmError = nullptr; + _dnsServer = nullptr; #endif } @@ -1036,6 +1039,10 @@ public: _otaServer->handleClient(); } } + // Process DNS for captive portal redirect (file manager only) + if (_dnsServer && _editMode == EDIT_FILEMGR && _fmPhase == FM_PHASE_WAITING) { + _dnsServer->processNextRequest(); + } } // Called from main loop — detect upload completion and trigger flash. @@ -1132,52 +1139,67 @@ public: Serial.printf("FM: AP '%s' started, IP: %s\n", _otaApName, WiFi.softAPIP().toString().c_str()); + // Start DNS server — redirect ALL DNS lookups to our AP IP. + // This triggers captive portal detection on phones, which opens the + // page in a real browser instead of the restricted captive webview. + if (_dnsServer) { delete _dnsServer; } + _dnsServer = new DNSServer(); + _dnsServer->start(53, "*", WiFi.softAPIP()); + Serial.println("FM: DNS captive portal started"); + // Start web server if (_otaServer) { _otaServer->stop(); delete _otaServer; } _otaServer = new WebServer(80); - // --- Serve the file manager SPA --- - _otaServer->on("/", HTTP_GET, [this]() { - _otaServer->send(200, "text/html", fileMgrPageHTML()); + // --- Captive portal detection handlers --- + // Phones/OS probe these URLs to detect captive portals. Redirecting + // them to our page causes the OS to open a real browser. + // iOS / macOS + _otaServer->on("/hotspot-detect.html", HTTP_GET, [this]() { + Serial.println("FM: captive probe (Apple)"); + _otaServer->sendHeader("Location", "http://192.168.4.1/"); + _otaServer->send(302, "text/plain", ""); + }); + // Android + _otaServer->on("/generate_204", HTTP_GET, [this]() { + Serial.println("FM: captive probe (Android)"); + _otaServer->sendHeader("Location", "http://192.168.4.1/"); + _otaServer->send(302, "text/plain", ""); + }); + _otaServer->on("/gen_204", HTTP_GET, [this]() { + _otaServer->sendHeader("Location", "http://192.168.4.1/"); + _otaServer->send(302, "text/plain", ""); + }); + // Windows + _otaServer->on("/connecttest.txt", HTTP_GET, [this]() { + _otaServer->sendHeader("Location", "http://192.168.4.1/"); + _otaServer->send(302, "text/plain", ""); + }); + _otaServer->on("/redirect", HTTP_GET, [this]() { + _otaServer->sendHeader("Location", "http://192.168.4.1/"); + _otaServer->send(302, "text/plain", ""); + }); + // Firefox + _otaServer->on("/canonical.html", HTTP_GET, [this]() { + _otaServer->sendHeader("Location", "http://192.168.4.1/"); + _otaServer->send(302, "text/plain", ""); + }); + _otaServer->on("/success.txt", HTTP_GET, [this]() { + _otaServer->send(200, "text/plain", "success"); }); - // --- Directory listing: GET /api/ls?path=/ --- - _otaServer->on("/api/ls", HTTP_GET, [this]() { + // --- Main page: server-rendered directory listing (no JS needed) --- + _otaServer->on("/", HTTP_GET, [this]() { String path = _otaServer->arg("path"); if (path.isEmpty()) path = "/"; - File dir = SD.open(path); - if (!dir || !dir.isDirectory()) { - dir.close(); digitalWrite(SDCARD_CS, HIGH); - _otaServer->send(404, "application/json", "{\"error\":\"Not found\"}"); - return; - } - String json = "["; - bool first = true; - File entry; - while ((entry = dir.openNextFile())) { - if (!first) json += ","; - first = false; - // Extract basename — entry.name() may return full path on some ESP32 cores - const char* fullName = entry.name(); - const char* baseName = strrchr(fullName, '/'); - baseName = baseName ? baseName + 1 : fullName; - json += "{\"n\":\""; - json += baseName; - json += "\",\"s\":"; - json += String((unsigned long)entry.size()); - json += ",\"d\":"; - json += entry.isDirectory() ? "1" : "0"; - json += "}"; - entry.close(); - } - dir.close(); - digitalWrite(SDCARD_CS, HIGH); - json += "]"; - _otaServer->send(200, "application/json", json); + String msg = _otaServer->arg("msg"); + Serial.printf("FM: page request path='%s'\n", path.c_str()); + String html = fmBuildPage(path, msg); + _otaServer->send(200, "text/html", html); }); - // --- File download: GET /api/dl?path=/file.txt --- - _otaServer->on("/api/dl", HTTP_GET, [this]() { + // --- File download: GET /dl?path=/file.txt --- + _otaServer->on("/dl", HTTP_GET, [this]() { String path = _otaServer->arg("path"); File f = SD.open(path, FILE_READ); if (!f || f.isDirectory()) { @@ -1186,14 +1208,11 @@ public: _otaServer->send(404, "text/plain", "Not found"); return; } - // Extract filename for Content-Disposition String name = path; int lastSlash = name.lastIndexOf('/'); if (lastSlash >= 0) name = name.substring(lastSlash + 1); _otaServer->sendHeader("Content-Disposition", "attachment; filename=\"" + name + "\""); - - // Stream file in chunks — no full-file RAM allocation size_t fileSize = f.size(); _otaServer->setContentLength(fileSize); _otaServer->send(200, "application/octet-stream", ""); @@ -1210,10 +1229,13 @@ public: digitalWrite(SDCARD_CS, HIGH); }); - // --- File upload: POST /api/upload?dir=/ --- - _otaServer->on("/api/upload", HTTP_POST, + // --- File upload: POST /upload?dir=/ → redirect back to listing --- + _otaServer->on("/upload", HTTP_POST, [this]() { - _otaServer->send(200, "application/json", "{\"ok\":true}"); + String dir = _otaServer->arg("dir"); + if (dir.isEmpty()) dir = "/"; + _otaServer->sendHeader("Location", "/?path=" + dir + "&msg=Upload+complete"); + _otaServer->send(303, "text/plain", "Redirecting..."); }, [this]() { HTTPUpload& upload = _otaServer->upload(); @@ -1226,14 +1248,10 @@ public: String fullPath = dir + upload.filename; Serial.printf("FM: Upload start: %s\n", fullPath.c_str()); fmUploadFile = SD.open(fullPath, FILE_WRITE); - if (!fmUploadFile) { - Serial.println("FM: Failed to open file for write"); - } + if (!fmUploadFile) Serial.println("FM: Failed to open file for write"); } else if (upload.status == UPLOAD_FILE_WRITE) { - if (fmUploadFile) { - fmUploadFile.write(upload.buf, upload.currentSize); - } + if (fmUploadFile) fmUploadFile.write(upload.buf, upload.currentSize); } else if (upload.status == UPLOAD_FILE_END) { if (fmUploadFile) { @@ -1251,24 +1269,33 @@ public: } ); - // --- Create directory: GET /api/mkdir?path=/newfolder --- - _otaServer->on("/api/mkdir", HTTP_GET, [this]() { - String path = _otaServer->arg("path"); - if (path.isEmpty()) { - _otaServer->send(400, "application/json", "{\"error\":\"No path\"}"); + // --- Create directory: GET /mkdir?name=xxx&dir=/path --- + _otaServer->on("/mkdir", HTTP_GET, [this]() { + String dir = _otaServer->arg("dir"); + String name = _otaServer->arg("name"); + if (dir.isEmpty()) dir = "/"; + if (name.isEmpty()) { + _otaServer->sendHeader("Location", "/?path=" + dir + "&msg=No+name"); + _otaServer->send(303); return; } - bool ok = SD.mkdir(path); + String full = dir + (dir.endsWith("/") ? "" : "/") + name; + bool ok = SD.mkdir(full); digitalWrite(SDCARD_CS, HIGH); - _otaServer->send(ok ? 200 : 500, "application/json", - ok ? "{\"ok\":true}" : "{\"error\":\"mkdir failed\"}"); + Serial.printf("FM: mkdir '%s' %s\n", full.c_str(), ok ? "OK" : "FAIL"); + _otaServer->sendHeader("Location", + "/?path=" + dir + "&msg=" + (ok ? "Folder+created" : "mkdir+failed")); + _otaServer->send(303); }); - // --- Delete file/folder: GET /api/rm?path=/file.txt --- - _otaServer->on("/api/rm", HTTP_GET, [this]() { + // --- Delete file/folder: GET /rm?path=/file&ret=/parent --- + _otaServer->on("/rm", HTTP_GET, [this]() { String path = _otaServer->arg("path"); + String ret = _otaServer->arg("ret"); + if (ret.isEmpty()) ret = "/"; if (path.isEmpty() || path == "/") { - _otaServer->send(400, "application/json", "{\"error\":\"Bad path\"}"); + _otaServer->sendHeader("Location", "/?path=" + ret + "&msg=Bad+path"); + _otaServer->send(303); return; } File f = SD.open(path); @@ -1279,8 +1306,44 @@ public: ok = isDir ? SD.rmdir(path) : SD.remove(path); } digitalWrite(SDCARD_CS, HIGH); - _otaServer->send(ok ? 200 : 500, "application/json", - ok ? "{\"ok\":true}" : "{\"error\":\"Delete failed\"}"); + Serial.printf("FM: rm '%s' %s\n", path.c_str(), ok ? "OK" : "FAIL"); + _otaServer->sendHeader("Location", + "/?path=" + ret + "&msg=" + (ok ? "Deleted" : "Delete+failed")); + _otaServer->send(303); + }); + + // --- Confirm delete page: GET /confirm-rm?path=/file&ret=/parent --- + _otaServer->on("/confirm-rm", HTTP_GET, [this]() { + String path = _otaServer->arg("path"); + String ret = _otaServer->arg("ret"); + if (ret.isEmpty()) ret = "/"; + String name = path; + int sl = name.lastIndexOf('/'); + if (sl >= 0) name = name.substring(sl + 1); + String html = "" + "" + "" + "Confirm Delete" + "" + "

Delete?

" + "

" + fmHtmlEscape(name) + "

" + "Delete" + "Cancel" + ""; + _otaServer->send(200, "text/html", html); + }); + + // Catch-all: redirect unknown URLs to file manager (catches captive portal probes) + _otaServer->onNotFound([this]() { + Serial.printf("FM: redirect %s -> /\n", _otaServer->uri().c_str()); + _otaServer->sendHeader("Location", "http://192.168.4.1/"); + _otaServer->send(302, "text/plain", ""); }); _otaServer->begin(); @@ -1290,6 +1353,7 @@ public: void stopFileMgr() { if (_otaServer) { _otaServer->stop(); delete _otaServer; _otaServer = nullptr; } + if (_dnsServer) { _dnsServer->stop(); delete _dnsServer; _dnsServer = nullptr; } WiFi.softAPdisconnect(true); WiFi.mode(WIFI_OFF); delay(100); @@ -1303,10 +1367,51 @@ public: Serial.println("FM: Stopped, AP down, radio resumed"); } - // --- File manager SPA HTML --- - static const char* fileMgrPageHTML() { - return - "" + // --- Helpers for server-rendered HTML --- + + static String fmHtmlEscape(const String& s) { + String r; + r.reserve(s.length()); + for (unsigned int i = 0; i < s.length(); i++) { + char c = s[i]; + if (c == '&') r += "&"; + else if (c == '<') r += "<"; + else if (c == '>') r += ">"; + else if (c == '"') r += """; + else r += c; + } + return r; + } + + static String fmUrlEncode(const String& s) { + String r; + for (unsigned int i = 0; i < s.length(); i++) { + char c = s[i]; + if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '/' || c == '~') { + r += c; + } else { + char hex[4]; + snprintf(hex, sizeof(hex), "%%%02X", (uint8_t)c); + r += hex; + } + } + return r; + } + + static String fmFormatSize(size_t bytes) { + if (bytes < 1024) return String(bytes) + " B"; + if (bytes < 1048576) return String(bytes / 1024) + " KB"; + return String(bytes / 1048576) + "." + String((bytes % 1048576) * 10 / 1048576) + " MB"; + } + + // Build the complete HTML page with inline directory listing + String fmBuildPage(const String& path, const String& msg) { + String html; + html.reserve(4096); + + // --- Head + CSS --- + html += "" + "" "" "Meck SD Files" "" - "

Meck SD File Manager

" - "
/
" - "
" - "" - "" - "" - "
" - "
    " - "
    " - "

    Tap to select files or drag and drop

    " - "" - "
    " - "
    " - "
    " - "
    " - ""; + ".ms{background:#16213e;padding:8px 12px;border-radius:6px;margin:8px 0;" + "border-left:3px solid #4ecca3;font-size:0.9em}" + ""; + + // --- Title + path --- + html += "

    Meck SD File Manager

    "; + html += "
    " + fmHtmlEscape(path) + "
    "; + + // --- Status message (from redirects) --- + if (msg.length() > 0) { + html += "
    " + fmHtmlEscape(msg) + "
    "; + } + + // --- Navigation buttons --- + html += "
    "; + if (path != "/") { + // Compute parent + String parent = path; + if (parent.endsWith("/")) parent = parent.substring(0, parent.length() - 1); + int sl = parent.lastIndexOf('/'); + parent = (sl <= 0) ? "/" : parent.substring(0, sl); + html += ".. Up"; + } + html += "Refresh"; + html += "
    "; + + // --- Directory listing (server-rendered) --- + File dir = SD.open(path, FILE_READ); + if (!dir || !dir.isDirectory()) { + if (dir) dir.close(); + digitalWrite(SDCARD_CS, HIGH); + html += "
    Cannot open directory
    "; + } else { + // Collect entries into arrays for sorting (dirs first, then alpha) + struct FmEntry { String name; size_t size; bool isDir; }; + FmEntry entries[128]; // max entries to display + int count = 0; + File entry = dir.openNextFile(); + while (entry && count < 128) { + const char* fullName = entry.name(); + const char* baseName = strrchr(fullName, '/'); + baseName = baseName ? baseName + 1 : fullName; + entries[count].name = baseName; + entries[count].size = entry.size(); + entries[count].isDir = entry.isDirectory(); + count++; + entry.close(); + entry = dir.openNextFile(); + } + dir.close(); + digitalWrite(SDCARD_CS, HIGH); + + Serial.printf("FM: listing %d entries for '%s'\n", count, path.c_str()); + + // Sort: dirs first, then alphabetical + for (int i = 0; i < count - 1; i++) { + for (int j = i + 1; j < count; j++) { + bool swap = false; + if (entries[i].isDir != entries[j].isDir) { + swap = !entries[i].isDir && entries[j].isDir; + } else { + swap = entries[i].name.compareTo(entries[j].name) > 0; + } + if (swap) { + FmEntry tmp = entries[i]; + entries[i] = entries[j]; + entries[j] = tmp; + } + } + } + + if (count == 0) { + html += "
    Empty folder
    "; + } else { + for (int i = 0; i < count; i++) { + String fp = path + (path.endsWith("/") ? "" : "/") + entries[i].name; + html += "
    "; + html += "" + String(entries[i].isDir ? "\xF0\x9F\x93\x81" : "\xF0\x9F\x93\x84") + ""; + if (entries[i].isDir) { + html += "" + fmHtmlEscape(entries[i].name) + ""; + } else { + html += "" + fmHtmlEscape(entries[i].name) + ""; + html += "" + fmFormatSize(entries[i].size) + ""; + } + html += "Del"; + html += "
    "; + } + } + } + + // --- Upload form (standard HTML form, no JS needed) --- + html += "
    " + "
    " + "

    Select files to upload

    " + "

    " + "" + "
    "; + + // --- New folder (tiny inline form) --- + html += "
    " + "" + "" + "" + "
    "; + + html += ""; + return html; } #endif @@ -2121,10 +2230,10 @@ public: display.print("Start WiFi file server?"); oy += 10; display.setCursor(bx + 4, oy); - display.print("Browse, upload and download"); + display.print("Upload and download files"); oy += 8; display.setCursor(bx + 4, oy); - display.print("SD card files via browser."); + display.print("on SD card via browser."); oy += 10; display.setCursor(bx + 4, oy); display.setColor(DisplayDriver::YELLOW); diff --git a/variants/lilygo_t5s3_epaper_pro/platformio.ini b/variants/lilygo_t5s3_epaper_pro/platformio.ini index 41c0f3e..a670d38 100644 --- a/variants/lilygo_t5s3_epaper_pro/platformio.ini +++ b/variants/lilygo_t5s3_epaper_pro/platformio.ini @@ -64,6 +64,7 @@ build_src_filter = ${esp32_base.build_src_filter} lib_deps = ${esp32_base.lib_deps} WebServer + DNSServer Update ; --------------------------------------------------------------------------- diff --git a/variants/lilygo_tdeck_pro/platformio.ini b/variants/lilygo_tdeck_pro/platformio.ini index d9c584b..095b290 100644 --- a/variants/lilygo_tdeck_pro/platformio.ini +++ b/variants/lilygo_tdeck_pro/platformio.ini @@ -97,6 +97,7 @@ lib_deps = adafruit/Adafruit GFX Library@^1.11.0 bitbank2/PNGdec@^1.0.1 WebServer + DNSServer Update ; ---------------------------------------------------------------------------