The most annoying network failure is the one your Wi-Fi icon cannot see.

I have been working from coffee shops more than usual while pushing through an intense project. I know the obvious objection: a café is not a private office. I leave when the room gets busy, I keep buying drinks or snacks periodically, and I try not to turn a small table into permanent infrastructure.

Still, there are quiet stretches when the space is comfortable, the work is moving, and everything clicks. Then a terminal command hangs. A sync stops. A page spins. The Wi-Fi icon still looks healthy, but the internet path is gone because the captive portal session timed out and wants another acknowledgment.

The outage may last only a minute or two. The focus damage lasts longer. First I wonder whether the service is down. Then I check the VPN, DNS, and Wi-Fi. Eventually I remember the portal, open a plain HTTP page, wait for the redirect, accept the terms again, and reconstruct the mental state I had before the interruption.

After repeating that loop enough times, I stopped treating it as random annoyance. It was an observability problem. macOS knew the wireless interface was connected, but my working definition of "connected" was stricter: the application path had to reach the public internet.

So I wrote a small watcher. It does not keep a session alive, click an acceptance button, or defeat the café's policy. It watches for the gap between Wi-Fi association and usable internet access, notifies me when the evidence is strong enough, and reopens the human sign-in step.

If you're a developer, operator, remote worker, or curious macOS user, this post shows how that layered failure works, why I chose a conservative design, and how you can run or adapt the complete script.

Thesis: Captive-portal timeouts are layered-state failures, so the useful fix is observability, not bypass.

Why now: Mobile work increasingly depends on public networks whose authorization state can expire without breaking the Wi-Fi link.

Who should care: Developers, operators, remote workers, and macOS users who lose expensive focus to silent network interruptions.

Bottom line: Detect the mismatch, surface the portal, and keep consent in human hands.

Boundary condition: This is for permitted session renewal on a network you are allowed to use, not for evading limits or occupying scarce café space indefinitely.

Key Ideas

  • A healthy Wi-Fi link proves local association, not usable internet access.
  • Known-response HTTP probes can reveal redirects, altered bodies, and failed paths that the menu-bar icon misses.
  • Multiple endpoints and consecutive observations reduce false alarms without requiring a large monitoring system.
  • Safe automation should restore visibility and choice, not automatically accept a network operator's terms.

The Wi-Fi did not disconnect. My authorization did.

The first mistake was treating "connected" as one state.

At the wireless layer, the Mac was still associated with the café access point. It still had a local IP address. Nothing had happened that required the Wi-Fi interface to disconnect.

At the authorization layer, however, the network had stopped allowing normal external traffic. A captive portal is a network control that limits broader access until you satisfy a requirement such as signing in, entering a code, or accepting terms. The IETF captive-portal architecture describes this as a state in which a device is voluntarily attached to a network but external access remains restricted.

That difference creates what I call the connected-but-unusable gap. The link is healthy enough to look normal. The path is unhealthy enough to break the work.

Field note: A green Wi-Fi icon is a link-state report, not a promise.

This is why the interruption initially feels random. Most of us read the icon as a summary of the whole network. It is really evidence about one layer.

In my experience, the portal timeout rarely announces itself at the portal layer first. It appears as a frozen dependency install, a stalled sync, or a page that suddenly stops moving.

LayerWhat it provesWhat it does not prove
Wi-Fi associationThe Mac is attached to an access pointThe internet is reachable
Local addressingThe interface has a usable local IP addressThe session is authorized
Portal authorizationThe network currently permits broader accessEvery public service is healthy
Application pathA specific service is reachable and respondingThe rest of the internet is healthy

Once I separated those layers, the design became much simpler. I did not need to "fix Wi-Fi." I needed to observe enough layers to distinguish a disconnected interface from an intercepted internet path.

One green icon was hiding four different states

The watcher uses a deliberately small state model:

flowchart TD
  A[Check Wi-Fi interface] --> B{Associated and addressed?}
  B -->|No| C[Wi-Fi disconnected]
  B -->|Yes| D[Run three known-response HTTP probes]
  D --> E{How many exact responses?}
  E -->|Two or three| F[Online]
  E -->|One| G[Uncertain]
  E -->|Zero| H[Intercepted or externally unreachable]
  H --> I{Failed twice in a row?}
  I -->|No| A
  I -->|Yes| J[Notify and open portal-discovery page]
  J --> K{Recovered twice in a row?}
  K -->|No| A
  K -->|Yes| F

The intercepted label is intentionally conservative. Zero successful probes can mean a captive portal, but it can also mean broken DNS, a total upstream outage, an aggressive firewall, or a network that blocks the probe hosts. The script detects a suspicious state. It does not claim forensic certainty.

Plain-language decode: The failure and recovery thresholds are a debounce control. The watcher waits for the same answer twice before changing state, just as a physical switch may ignore one noisy electrical sample.

This distinction matters because a useful tool should not turn every transient packet loss into a browser interruption. The design optimizes for fast recovery without pretending every failed request has the same cause.

At this point, the architecture has one clear job: turn a layered state mismatch into evidence strong enough to justify an interruption.

I refused to turn a convenience script into a bypass

There was an obvious temptation: if the portal page is predictable, why not have the script click the acceptance button too?

Because that crosses the wrong boundary.

Portal forms vary. Some require a room number, phone number, one-time code, purchase, or new terms. Even when the button only says "Continue," automating it can misrepresent consent and may violate the operator's policy. It also trains the tool to perform an action on a page supplied by an untrusted local network.

The script therefore stops at the human decision point. It sends a macOS notification and opens a plain HTTP discovery page that a captive network can redirect. I still inspect the page and decide whether to continue.

Safety boundary: Automate detection and navigation. Do not automate consent.

This is also the correct social boundary. The tool is not an argument that a café owes me unlimited working time. It only removes a needless diagnostic loop when the venue permits another session and I am still using the space responsibly.

The watcher trusts agreement, not a single endpoint

The core detection mechanism is not novel. Operating systems already use known-response probes to distinguish normal internet access from captive behavior.

Android documentation describes cleartext HTTP probes to known destinations, where a redirect can indicate a captive portal. Microsoft documents a similar pattern for the Windows Network Connectivity Status Indicator: request a known file, expect a specific status and body, and treat a redirect or mismatched payload as evidence of a hotspot or portal.

I borrowed the mechanism, then made the evidence rule more conservative for a user-space script.

The watcher checks three endpoints:

readonly -a PROBES=(
  "http://connectivitycheck.gstatic.com/generate_204|204|"
  "http://www.msftconnecttest.com/connecttest.txt|200|Microsoft Connect Test"
  "http://captive.apple.com/hotspot-detect.html|200|<HTML><HEAD><TITLE>Success</TITLE></HEAD><BODY>Success</BODY></HTML>"
)

Each specification contains a URL, expected HTTP status, and optional exact body. Redirect following is disabled. That is important because a portal redirect should count as altered behavior, not as a successful trip to the portal page.

The state classifier then applies two-of-three confidence:

connectivity_state() {
  local successes=0
  local probe

  for probe in "${PROBES[@]}"; do
    if probe_endpoint "$probe"; then
      (( successes += 1 ))
    fi
  done

  if (( successes >= 2 )); then
    print "online"
  elif (( successes == 1 )); then
    print "uncertain"
  else
    print "intercepted"
  fi
}

One failed provider does not trigger a portal event. One successful provider does not prove the path is healthy either. Agreement among independent endpoints is more useful than trusting any single service.

This is not a formal quorum in the distributed-systems sense. It is a cheap confidence mechanism with understandable failure behavior.

The control loop is deliberately boring

Detection is only half of the problem. The other half is deciding when to interrupt me.

The watcher checks every 20 seconds by default. It requires two consecutive intercepted rounds before alerting, two consecutive online rounds before declaring recovery, and a five-minute cooldown before reopening a persistent portal.

Those defaults create a calmer operating artifact. The before state is a reactive diagnosis loop. The after state is an instrumented recovery loop:

Before: reactive diagnosisAfter: instrumented recovery
Notice a frozen commandProbe runs in the background
Guess whether the service, VPN, DNS, or Wi-Fi failedLink state separates Wi-Fi loss from external-path failure
Manually try random pagesExact-response probes classify the path
Remember captive portals after several checksTwo failed rounds trigger a notification
Find a plain HTTP pageWatcher opens the discovery page
Guess when recovery is completeTwo good rounds confirm restoration

The operational result is not uninterrupted internet. It is a shorter, more legible failure.

That is an important reliability principle: when you cannot prevent a failure, reduce the time spent misunderstanding it.

The script also rate-limits its own helpfulness. Without the cooldown, a persistent outage could keep opening browser tabs every check cycle. A recovery threshold prevents one lucky response from immediately clearing the active state.

The code separates evidence from action

I wanted the script to stay inspectable. The evidence functions gather state. The action functions notify or open a page. The main loop connects them.

Now we can move from the control model into the implementation choices that make it work on macOS.

First, it finds the macOS Wi-Fi device instead of assuming it is always en0:

find_wifi_device() {
  /usr/sbin/networksetup -listallhardwareports 2>/dev/null |
    /usr/bin/awk '
      /^Hardware Port: (Wi-Fi|AirPort)$/ {
        getline
        if ($1 == "Device:") {
          print $2
          exit
        }
      }
    '
}

Then it checks both interface activity and local addressing:

wifi_link_state() {
  local device="$1"

  if [[ -z "$device" ]]; then
    print "unknown"
    return
  fi

  if ! /sbin/ifconfig "$device" 2>/dev/null |
    /usr/bin/grep -q "status: active"; then
    print "disconnected"
    return
  fi

  if ! /usr/sbin/ipconfig getifaddr "$device" >/dev/null 2>&1; then
    print "disconnected"
    return
  fi

  print "connected"
}

The probe function uses curl with redirects disabled, a short timeout, and an exact response comparison:

actual_status=$(
  /usr/bin/curl \
    --silent \
    --output "$body_file" \
    --write-out '%{http_code}' \
    --connect-timeout 3 \
    --max-time "$CURL_TIMEOUT" \
    --max-redirs 0 \
    "$url" 2>/dev/null
)

Finally, the action remains intentionally modest:

open_portal() {
  /usr/bin/open "http://captive.apple.com/hotspot-detect.html" \
    >/dev/null 2>&1 ||
  /usr/bin/open "http://neverssl.com/" >/dev/null 2>&1 ||
  log "WARN" "Could not open a portal-discovery page."
}

Plain HTTP is intentional here. A captive network can intercept that request and redirect the browser to its portal. HTTPS is designed to resist that kind of transparent redirection, which is good for normal security but less useful for discovering a portal.

The complete script includes validation, logging, notifications, signal handling, configurable thresholds, and a one-shot mode. You can download the latest release from GitHub, inspect the public source repository, or use the site-hosted copy.

Install it like a tool you may need to inspect

This is a macOS zsh script. It uses built-in system utilities plus curl, so there is no package installation. The sequence matters, but it is short.

Download the script and make it executable.

curl -o ~/captive-portal-watch.command \
  https://nat.io/downloads/captive-portal-watch.command
chmod +x ~/captive-portal-watch.command

Run a single diagnostic check first.

~/captive-portal-watch.command --once --no-open --verbose

Start the foreground watcher when the output looks reasonable.

~/captive-portal-watch.command

Stop it with Control-C, then review the default log if you need to understand a state change.

tail -f ~/Library/Logs/captive-portal-watch.log

The one-shot mode returns distinct exit codes: 0 for online, 3 for uncertain, 4 for intercepted, and 5 for Wi-Fi disconnected. That makes it useful in shell checks without scraping prose.

You can tune behavior through environment variables:

CHECK_INTERVAL=30 \
FAIL_THRESHOLD=3 \
PORTAL_COOLDOWN=600 \
~/captive-portal-watch.command

If I decide to run it continuously, I would wrap it in a user LaunchAgent so macOS starts it at login and restarts it after failure. I would not begin there. A network observer should spend some time in the foreground where its assumptions and false positives are visible.

Machine-readable state makes the script composable

This started as a personal irritation, but the implementation works better because its outputs are not only for me.

The one-shot exit codes are machine-readable. The timestamped log is parseable. The thresholds are environment-controlled. Those choices let the script participate in a LaunchAgent, a menu-bar wrapper, a monitoring check, or an AI-assisted troubleshooting workflow without changing its core.

That composability still needs an accountability boundary. Automation may detect that the path is captive and bring the portal forward. It should not claim that clicking the portal is safe, authorized, or appropriate. An AI agent can explain the evidence and suggest the next action. The human remains responsible for the network terms and the page being presented.

The principle-to-result chain is straightforward. The principle is that link state is not internet state. The example is a Mac that remains on Wi-Fi after portal authorization expires. The artifact combines interface evidence, exact-response probes, thresholds, logs, and exit codes. The operational result is an interruption that becomes visible and recoverable without automating consent.

That is what makes the script more than a loop around curl. It encodes a boundary.

The obvious objections are mostly right

The simplest objection is that I should use my phone hotspot. Sometimes I do. If the café network is unstable, the portal is aggressive, or I need a high-confidence connection for a call or deployment, the hotspot is the better tool.

Another objection is that I should leave. Also correct when the room is busy, the venue discourages long work sessions, or I have already occupied more space than my purchases reasonably support.

A third objection is that macOS already has captive-portal detection. It does, and often it works. This script exists for the stale-session case where the operating system does not surface the portal quickly enough for my workflow.

There are also technical limits:

  • A DNS outage or total upstream failure can look like interception because every probe fails.
  • A network may block one or more probe endpoints for policy reasons.
  • VPNs, proxies, filtering software, and privacy tools can change probe behavior.
  • Portal implementations vary, so opening a discovery page cannot guarantee a useful redirect.
  • Public Wi-Fi remains untrusted. Inspect the destination before entering credentials or sensitive information.

The list stays because these are distinct failure classes a user should scan before trusting the tool. Together they reinforce the same point: this watcher is a conservative detector, not a source of network truth.

Use the smallest control that protects the work

I now use a simple decision rule.

  • Use the watcher when the Wi-Fi is otherwise stable, renewed access is permitted, and silent expiry is the recurring problem.
  • Use a hotspot when connection confidence matters more than conserving mobile data.
  • Leave or change venues when the room is busy, the policy is unfriendly to extended work, or the network itself is broadly unreliable.

This is not about maximizing the number of hours I can extract from a coffee shop. It is about matching the control to the failure.

So far, the useful reframe is bigger than captive portals. A lot of operational frustration comes from systems that report success at one layer while failing at the layer the user actually cares about. The menu bar says connected. The application says otherwise. The wasted time lives between those truths.

Reliability starts where the green icon stops

When the connection drops now, I still have to pause. I still have to look at the portal and decide whether to continue. The network still belongs to the venue, and its limits are still real.

What changed is the ambiguity.

Instead of burning several minutes asking the wrong questions, I get a notification that says the external path looks intercepted. The browser opens. I make the decision. The watcher confirms recovery. Then I return to the work before the interruption turns into a full context switch.

The script is small because the problem is not really "how do I automate Wi-Fi?" The problem is how to notice when two layers disagree.

Good reliability tooling does not merely keep systems running. It tells the truth quickly when they are not.