From 7c7e4745cf6643f8582bb2120863fb579089765f Mon Sep 17 00:00:00 2001 From: Matt Edholm Date: Sat, 9 May 2026 10:40:01 -0400 Subject: [PATCH] fix(provisioning): captive portal opens reliably on iOS lock-screen scans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous handler answered Apple/Android/Windows CNA probes with HTTP 302 redirects to "/". That works in a desktop browser, but iOS — particularly when joining via the lock-screen camera quick-scan path — sometimes treats the redirect as "internet works" and never raises the captive banner. The user has to remember the manual fallback URL on the e-ink footer to recover. Switch every probe URL to serve the portal HTML directly with 200 OK. A 200 response whose body is not Apple's magic Success page is the canonical "this is a captive network" signal; banner-fire becomes deterministic on the first probe. While here: - Register HTTP handlers BEFORE softAP comes up so the very first probe from a fast-joining device lands on a ready server, not connection- refused. - Drop the unconditional 500 ms post-softAP delay; softAPIP is valid immediately and the gap was just a window for races. - Add /library/test/success.html (iOS legacy) and /connecttest.txt (Windows 10+) to the explicit handler list. - Delete handle_captive (was the 302 redirect path). Locked-phone caveat: iOS by design will not auto-open the captive portal UI while the phone is locked — the best we can do is make the banner notification fire reliably so it's waiting on unlock. This change accomplishes that. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main.cpp | 40 ++++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 2f1de85..ed8c9fa 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -112,12 +112,6 @@ static void handle_connect() { server.send_P(200, "text/html", CONNECTING_HTML); } -// Captive portal detection endpoints — all redirect to portal -static void handle_captive() { - server.sendHeader("Location", "http://" AP_IP "/"); - server.send(302, "text/plain", ""); -} - // ── WiFi provisioning ───────────────────────────────────────────────────────── static void enter_provisioning(const String& mac, bool retry = false) { @@ -130,23 +124,37 @@ static void enter_provisioning(const String& mac, bool retry = false) { Serial.println(retry ? "AP (retry): " + apSsid : "AP: " + apSsid); + // Register handlers BEFORE softAP comes up so a fast-joining iOS + // device that probes immediately never hits a connection-refused. + // iOS CNA probes the moment DHCP completes; if the first probe + // fails the network sometimes gets marked as broken and the + // captive banner never fires. + // + // Every CNA probe path (Apple, Android, Windows) is routed straight + // to handle_root — i.e. 200 OK with the portal HTML body. We used + // to 302-redirect to "/", which works on a desktop browser but is + // unreliable from the iOS lock-screen-camera join path: iOS + // occasionally treats the redirect as "internet works" and skips + // the captive banner. A 200 with a non-"Success" body is the + // canonical signal that this is a captive network. + server.on("/", HTTP_GET, handle_root); + server.on("/connect", HTTP_POST, handle_connect); + server.on("/hotspot-detect.html", handle_root); // iOS / macOS + server.on("/library/test/success.html", handle_root); // iOS legacy + server.on("/generate_204", handle_root); // Android + server.on("/gen_204", handle_root); // Android variant + server.on("/ncsi.txt", handle_root); // Windows + server.on("/connecttest.txt", handle_root); // Windows 10+ + server.onNotFound(handle_root); + WiFi.disconnect(true); WiFi.mode(WIFI_AP); WiFi.softAP(apSsid.c_str()); - delay(500); - // Redirect all DNS to this device + server.begin(); dns.setErrorReplyCode(DNSReplyCode::NoError); dns.start(53, "*", WiFi.softAPIP()); - server.on("/", HTTP_GET, handle_root); - server.on("/connect", HTTP_POST, handle_connect); - server.on("/generate_204", handle_captive); - server.on("/hotspot-detect.html", handle_captive); - server.on("/ncsi.txt", handle_captive); - server.onNotFound(handle_root); - server.begin(); - // On retry, repaint with red accents + "Connection Failed — try again" // label so the user has a clear visual signal that their last credential // entry didn't work. On first entry, paint the standard yellow Step 1/2.