fix(provisioning): captive portal opens reliably on iOS lock-screen scans

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 10:40:01 -04:00
parent 05e869d190
commit 7c7e4745cf
+24 -16
View File
@@ -112,12 +112,6 @@ static void handle_connect() {
server.send_P(200, "text/html", CONNECTING_HTML); 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 ───────────────────────────────────────────────────────── // ── WiFi provisioning ─────────────────────────────────────────────────────────
static void enter_provisioning(const String& mac, bool retry = false) { 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); 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.disconnect(true);
WiFi.mode(WIFI_AP); WiFi.mode(WIFI_AP);
WiFi.softAP(apSsid.c_str()); WiFi.softAP(apSsid.c_str());
delay(500);
// Redirect all DNS to this device server.begin();
dns.setErrorReplyCode(DNSReplyCode::NoError); dns.setErrorReplyCode(DNSReplyCode::NoError);
dns.start(53, "*", WiFi.softAPIP()); 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" // On retry, repaint with red accents + "Connection Failed — try again"
// label so the user has a clear visual signal that their last credential // 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. // entry didn't work. On first entry, paint the standard yellow Step 1/2.