<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Dmytro Tishchenko</title>
    <description>The latest articles on DEV Community by Dmytro Tishchenko (@dtisch).</description>
    <link>https://dev.to/dtisch</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3942885%2F7a15f5d0-d6ca-44f9-8822-4ba564ac2601.png</url>
      <title>DEV Community: Dmytro Tishchenko</title>
      <link>https://dev.to/dtisch</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dtisch"/>
    <language>en</language>
    <item>
      <title>What I learned building 9 network tools on iOS without entitlements</title>
      <dc:creator>Dmytro Tishchenko</dc:creator>
      <pubDate>Thu, 04 Jun 2026 09:08:23 +0000</pubDate>
      <link>https://dev.to/dtisch/what-i-learned-building-9-network-tools-on-ios-without-entitlements-10ii</link>
      <guid>https://dev.to/dtisch/what-i-learned-building-9-network-tools-on-ios-without-entitlements-10ii</guid>
      <description>&lt;p&gt;A few weeks ago I shipped NetDiag+, an iOS network toolkit — ping, traceroute,&lt;br&gt;
DNS, whois, port/LAN scan — built entirely on BSD sockets through C-interop, with&lt;br&gt;
&lt;strong&gt;no private entitlements&lt;/strong&gt;. &lt;a href="https://dev.to/dtisch/how-i-implemented-ping-and-traceroute-on-ios-without-entitlements-2fm"&gt;I wrote up the ping/traceroute internals here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Then I spent a month adding &lt;strong&gt;9 more tools&lt;/strong&gt;. I expected "more of the same" — wrap a&lt;br&gt;
socket, draw a SwiftUI list. Instead almost every tool turned into its own little&lt;br&gt;
research project. Here are the parts that surprised me.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. MTR — a continuous traceroute that can't be slow
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;mtr&lt;/code&gt; keeps probing every hop and accumulates per-hop loss + RTT stats. My first&lt;br&gt;
version probed hops &lt;strong&gt;sequentially&lt;/strong&gt;: TTL=1, wait for reply or timeout, TTL=2, wait…&lt;br&gt;
On a 12-hop path where a couple of routers rate-limit ICMP, one cycle took &lt;strong&gt;5-7&lt;br&gt;
seconds&lt;/strong&gt;. Set the interval to "1 second" and the cycle counter ticks every five.&lt;/p&gt;

&lt;p&gt;Fix: fire all probes in a cycle &lt;strong&gt;at once&lt;/strong&gt; and collect replies in a single window.&lt;br&gt;
The trick is matching an incoming ICMP reply to the TTL that triggered it. I give&lt;br&gt;
each probe a unique destination UDP port (&lt;code&gt;base + ttl + cycle_offset&lt;/code&gt;). When a router&lt;br&gt;
returns ICMP Time Exceeded it includes the first bytes of the original packet — UDP&lt;br&gt;
header included — so I demultiplex on that port:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;/// Pull the dest port out of the UDP header embedded in an ICMP Time-Exceeded&lt;/span&gt;
&lt;span class="c1"&gt;/// error, so we know which outstanding probe this reply belongs to.&lt;/span&gt;
&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;extractInnerDestPort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="nv"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;UInt16&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;icmpHeader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;icmpHeader&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;ihlBytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;icmpHeader&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0x0F&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;   &lt;span class="c1"&gt;// inner IP header length&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;ihlBytes&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;innerUDP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;icmpHeader&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;ihlBytes&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;innerUDP&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;UInt16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;innerUDP&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kt"&gt;UInt16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;innerUDP&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now cycle time ≈ &lt;code&gt;probeTimeout&lt;/code&gt;, independent of how many hops are timing out.&lt;/p&gt;

&lt;p&gt;For the stats, storing every RTT and recomputing stddev leaks memory in a tool that&lt;br&gt;
runs forever. &lt;strong&gt;Welford's online algorithm&lt;/strong&gt; updates mean + variance in O(1):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="n"&gt;recv&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rttMs&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;mean&lt;/span&gt;
&lt;span class="n"&gt;mean&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;m2&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rttMs&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;stdDevMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;recv&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m2&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recv&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;squareRoot&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Path MTU — the obvious approach silently lied
&lt;/h3&gt;

&lt;p&gt;Path MTU = the largest packet that reaches the destination unfragmented. Classic VPN&lt;br&gt;
debugging: if your tunnel caps MTU at 1420 and the app sends 1500 with DF set, packets&lt;br&gt;
vanish and "the internet works but half the sites don't load."&lt;/p&gt;

&lt;p&gt;The algorithm is a binary search: send a Don't-Fragment packet of growing size, watch&lt;br&gt;
for ICMP "Fragmentation Needed." My first version sent &lt;strong&gt;UDP&lt;/strong&gt; to a random high port&lt;br&gt;
and treated ICMP Port Unreachable as the "it arrived" signal.&lt;/p&gt;

&lt;p&gt;Testing against &lt;code&gt;1.1.1.1&lt;/code&gt; it reported &lt;strong&gt;Path MTU = 688 bytes&lt;/strong&gt; — nonsense. Cloudflare&lt;br&gt;
(like most well-run hosts) &lt;strong&gt;silently drops&lt;/strong&gt; UDP to random ports, so Port Unreachable&lt;br&gt;
never comes back. Every timeout looked like "too big" and the search converged on junk.&lt;/p&gt;

&lt;p&gt;Rewrote it on &lt;strong&gt;ICMP Echo with DF&lt;/strong&gt;. Public hosts answer pings reliably, so "it fit"&lt;br&gt;
became a real signal. And when a router returns Frag-Needed it includes its &lt;strong&gt;next-hop&lt;br&gt;
MTU&lt;/strong&gt; (RFC 1191), so you can jump straight to the answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;extractNextHopMTU&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="nv"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;mtu&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;UInt16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kt"&gt;UInt16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;mtu&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mtu&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bonus: the discovered value hints at the link type — 1492 → PPPoE, 1480 → GRE,&lt;br&gt;
1420-1440 → WireGuard, 1400 → OpenVPN/IPSec.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Site Reach — TCP success doesn't mean "reachable"
&lt;/h3&gt;

&lt;p&gt;A tool that checks whether popular sites are reachable. Naive version: TCP-connect to&lt;br&gt;
443, success = "reachable." That &lt;strong&gt;misses the dominant modern block&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;National/corporate DPI often lets the TCP handshake complete and only injects RST after&lt;br&gt;
seeing the forbidden hostname in the &lt;strong&gt;SNI&lt;/strong&gt; field of the TLS Client Hello. From the&lt;br&gt;
device's view, TCP &lt;strong&gt;connected&lt;/strong&gt; — so a TCP-only probe reports "reachable" while HTTPS&lt;br&gt;
is actually dead.&lt;/p&gt;

&lt;p&gt;To catch it, go all the way to the TLS handshake. Two probes, compared:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;TCP&lt;/th&gt;
&lt;th&gt;TLS&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;fail&lt;/td&gt;
&lt;td&gt;fail&lt;/td&gt;
&lt;td&gt;IP block (or DNS pointing at a dead IP)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ok&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;fail&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;SNI / TLS block&lt;/strong&gt; — DPI killed the Client Hello by hostname&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;td&gt;actually reachable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The TLS probe uses &lt;code&gt;NWConnection&lt;/code&gt; with &lt;code&gt;NWProtocolTLS&lt;/code&gt;, which sets SNI to the&lt;br&gt;
connection's hostname automatically — exactly what trips SNI-aware DPI. Flagged sites&lt;br&gt;
get an "SNI" badge so they're distinct from plain unreachable ones.&lt;/p&gt;

&lt;h3&gt;
  
  
  The other six, briefly
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TLS Inspector&lt;/strong&gt; — probes TLS 1.0–1.3 in parallel (one &lt;code&gt;NWConnection&lt;/code&gt; per version,
min/max pinned), shows the negotiated cipher, flags deprecated 1.0/1.1 and weak ciphers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encrypted DNS&lt;/strong&gt; — resolves a domain over DoH and DoT at Cloudflare/Google/Quad9/
NextDNS, compares answers + latency. Hand-rolled minimal DNS wire codec.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;STUN / NAT&lt;/strong&gt; — real RFC 5389 Binding Request to several servers, parses
XOR-Mapped-Address, classifies NAT (Open / Cone / Symmetric) by whether the external
port differs per server. The signal for whether VoIP/WebRTC/P2P will work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bonjour browser&lt;/strong&gt; — &lt;code&gt;NWBrowser&lt;/code&gt; over ~20 mDNS service types (AirPlay, Chromecast,
HomeKit, printers) with TXT-record parsing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP/3 (QUIC)&lt;/strong&gt; — real QUIC handshake via &lt;code&gt;NWConnection&lt;/code&gt; + &lt;code&gt;NWProtocolQUIC&lt;/code&gt; with
ALPN "h3" on UDP/443. URLSession won't attempt QUIC without a cached Alt-Svc hint, so
this is the only honest way to check whether your network passes QUIC.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IPv6 Check&lt;/strong&gt; — local v6 enumeration with tunnel awareness (utun → iCloud Private
Relay / NAT66), NAT64/DNS64 detection (RFC 7050), v4-vs-v6 latency.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Four takeaways
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The "obvious" probe usually lies.&lt;/strong&gt; UDP-to-random-port (Path MTU), TCP-only (Site
Reach) — both give false results on real networks. Only a real protocol exchange —
ICMP Echo with DF, a full TLS handshake — tells the truth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raw ICMP/UDP on iOS is more accessible than you'd think&lt;/strong&gt; — a &lt;code&gt;SOCK_DGRAM&lt;/code&gt; ICMP
socket needs no entitlements and covers ping, traceroute, MTR, Path MTU. Raw SYN,
ARP, and packet capture are still off-limits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test on two networks.&lt;/strong&gt; Half my bugs only showed up comparing a clean European
Wi-Fi against a cellular CGNAT/symmetric-NAT link — the false "blocks" lived there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Network.framework&lt;/code&gt; is the right tool for TLS/QUIC.&lt;/strong&gt; Where ICMP means BSD sockets,
TLS Inspector and HTTP/3 are cleaner with &lt;code&gt;NWConnection&lt;/code&gt;: version pinning, ALPN, a
real QUIC handshake — all built in.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Everything still runs without private entitlements and collects no data — results stay&lt;br&gt;
on the device.&lt;/p&gt;

&lt;p&gt;NetDiag+ on the App Store: &lt;a href="https://apps.apple.com/app/id6761954529" rel="noopener noreferrer"&gt;https://apps.apple.com/app/id6761954529&lt;/a&gt; (12 languages, 25 tools)&lt;/p&gt;

&lt;p&gt;Happy to answer implementation questions in the comments.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>networking</category>
      <category>swift</category>
      <category>security</category>
    </item>
    <item>
      <title>How I implemented ping and traceroute on iOS without entitlements</title>
      <dc:creator>Dmytro Tishchenko</dc:creator>
      <pubDate>Thu, 21 May 2026 10:51:49 +0000</pubDate>
      <link>https://dev.to/dtisch/how-i-implemented-ping-and-traceroute-on-ios-without-entitlements-2fm</link>
      <guid>https://dev.to/dtisch/how-i-implemented-ping-and-traceroute-on-ios-without-entitlements-2fm</guid>
      <description>&lt;p&gt;I just shipped v1.3 of a network diagnostics app I've been building on evenings and weekends — NetDiag+. It does ping, traceroute, DNS lookups, whois, LAN scanning, port scanning, SSL cert checking, BGP/ASN lookups, plus a host monitor that does background pings and pushes a notification when something goes down.&lt;/p&gt;

&lt;p&gt;Stack: pure SwiftUI, iOS 16+, Swift Concurrency throughout, Darwin C-interop where it has to be. No third-party networking libraries — everything on raw BSD sockets through C-interop.&lt;/p&gt;

&lt;p&gt;Wanted to share the implementation notes I would have appreciated finding myself when I started. A few things on iOS work differently than you'd expect from a Unix background, and the gotchas aren't where you think they are.&lt;/p&gt;

&lt;p&gt;Spoiler on conclusions: the most painful part wasn't networking, it was integrating Google's UMP consent SDK for AdMob.&lt;/p&gt;

&lt;h2&gt;
  
  
  ICMP ping without entitlements
&lt;/h2&gt;

&lt;p&gt;The first surprise: on iOS, you can open an ICMP socket &lt;strong&gt;without any special entitlement and without root&lt;/strong&gt;, which is something you'd need &lt;code&gt;CAP_NET_RAW&lt;/code&gt; or setuid for on Linux. Apple exposed this to regular sandboxed processes through &lt;code&gt;SOCK_DGRAM&lt;/code&gt; (not &lt;code&gt;SOCK_RAW&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="kt"&gt;ICMPSocket&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int32&lt;/span&gt;

    &lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;throws&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;fd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;AF_INET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;SOCK_DGRAM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;IPPROTO_ICMP&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;fd&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="kt"&gt;SocketError&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;creationFailed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;errno&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;deinit&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the same model macOS &lt;code&gt;ping(8)&lt;/code&gt; has used since some old version when Apple dropped the setuid bit. The kernel writes the correct ICMP header for you (type/code/checksum for echo request), and rewrites the identifier to one it generates. That last bit is the first gotcha: on recv you don't get back the identifier you sent, so the standard logic of matching responses by identifier doesn't work.&lt;/p&gt;

&lt;p&gt;The fix: match on &lt;strong&gt;sequence number and payload&lt;/strong&gt;. I put my own marker in the payload and verify it on receive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Send&lt;/span&gt;
&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;ICMPHeader&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;ICMPType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;echoRequest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rawValue&lt;/span&gt;
&lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;identifier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;   &lt;span class="c1"&gt;// kernel will overwrite&lt;/span&gt;
&lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sequence&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;currentSequence&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bigEndian&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;makePayload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;sequence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;currentSequence&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;packet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;packet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Receive&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;fromIP&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;receive&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;received&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;ICMPHeader&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="n"&gt;received&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kt"&gt;ICMPType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;echoReply&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rawValue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;received&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sequence&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;currentSequence&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bigEndian&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second gotcha: timeouts. &lt;code&gt;recvfrom&lt;/code&gt; without &lt;code&gt;SO_RCVTIMEO&lt;/code&gt; blocks indefinitely, which in Swift Concurrency means a stuck Task you then can't cancel cleanly. So I set a recv timeout on the socket:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;tv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;timeval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nv"&gt;tv_sec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nv"&gt;tv_usec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int32&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;truncatingRemainder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;dividingBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1_000_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;setsockopt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;SOL_SOCKET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;SO_RCVTIMEO&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;tv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;socklen_t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;MemoryLayout&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;timeval&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On &lt;code&gt;EAGAIN&lt;/code&gt;/&lt;code&gt;EWOULDBLOCK&lt;/code&gt; I throw my own &lt;code&gt;SocketError.timeout&lt;/code&gt;, which higher up in the stack becomes a normal "timeout" hop in the result list.&lt;/p&gt;

&lt;p&gt;Apple's SimplePing sample is the OG reference here — it's Objective-C, dated, but you can crib a lot from it. I ended up writing my own thin wrapper because SimplePing doesn't play nicely with modern Swift Concurrency and gets awkward for multi-host pings. No regrets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Traceroute that actually works
&lt;/h2&gt;

&lt;p&gt;Standard Unix traceroute has three flavors: ICMP echo (the Windows &lt;code&gt;tracert&lt;/code&gt; style), UDP to closed ports (classic BSD), or TCP SYN (paris-traceroute, traceroute-tcp).&lt;/p&gt;

&lt;p&gt;I tried the ICMP variant first. The idea is straightforward — send ICMP echo with a low TTL, listen for ICMP Time Exceeded from an intermediate hop, increment TTL. On paper, fine. In practice I hit two problems on iOS:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;setsockopt(IP_TTL)&lt;/code&gt; on a SOCK_DGRAM ICMP socket behaves inconsistently.&lt;/strong&gt; Some networks worked, others wouldn't return Time Exceeded reliably.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Matching incoming responses.&lt;/strong&gt; On receive you get back an ICMP Time Exceeded, and the inner payload contains the original packet you sent. Matching the response to a specific TTL step means parsing inner payloads, which felt fragile.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So I switched to the classic BSD approach: UDP packets to closed ports with incrementing TTL, with a passive ICMP socket listening for Time Exceeded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;icmpSock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="kt"&gt;ICMPSocket&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;// receive-only&lt;/span&gt;
&lt;span class="n"&gt;icmpSock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ttl&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="n"&gt;maxHops&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;udpSock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="kt"&gt;UDPSocket&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;udpSock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTTL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;dest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;address&lt;/span&gt;
    &lt;span class="n"&gt;dest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sin_port&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="kt"&gt;UInt16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ttl&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bigEndian&lt;/span&gt;  &lt;span class="c1"&gt;// 33434, 33435, ...&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;probe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;repeating&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;0x40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;sendTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CFAbsoluteTimeGetCurrent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;udpSock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;dest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;fromIP&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;icmpSock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;receive&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;rtt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CFAbsoluteTimeGetCurrent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;sendTime&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 33434+TTL port choice is &lt;code&gt;traceroute(8)&lt;/code&gt;'s historical pick from the 80s — those ports are unlikely to be open on the destination, so you're guaranteed to get either ICMP Time Exceeded from an intermediate hop or ICMP Port Unreachable from the destination, and both cases are handled uniformly.&lt;/p&gt;

&lt;p&gt;Key insight: &lt;strong&gt;UDP_TTL works fine on iOS&lt;/strong&gt; via &lt;code&gt;setsockopt(IP_TTL)&lt;/code&gt;, unlike the ICMP variant. The ICMP socket is purely a passive receiver — we never send from it, only read incoming Time Exceeded packets.&lt;/p&gt;

&lt;p&gt;Final hop detection is two-way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;isFinal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fromIP&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;destIP&lt;/span&gt;
    &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kt"&gt;ICMPType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;destinationUnreachable&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rawValue&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Either the response came from the destination IP (meaning it sent us Port Unreachable), or the ICMP type is Destination Unreachable.&lt;/p&gt;

&lt;h2&gt;
  
  
  LAN scanner: no ARP for you
&lt;/h2&gt;

&lt;p&gt;This is where iOS cuts harder. You &lt;strong&gt;don't get the system ARP table&lt;/strong&gt; in userspace. There's no &lt;code&gt;getarp()&lt;/code&gt;, no &lt;code&gt;/proc/net/arp&lt;/code&gt; like on Linux. So the obvious "read the ARP table and list neighbors" idea is dead on arrival.&lt;/p&gt;

&lt;p&gt;What you do get:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Concurrent TCP connect&lt;/strong&gt; on common ports (80, 443, 22, 8080, etc). If a host responds (SYN+ACK or RST), it's alive. Raw SYN is locked down — only a full TCP handshake works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mDNS / Bonjour discovery&lt;/strong&gt; via &lt;code&gt;NetServiceBrowser&lt;/code&gt; or &lt;code&gt;NWBrowser&lt;/code&gt;. Partial coverage — only sees devices that advertise services.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;NSLocalNetworkUsageDescription&lt;/code&gt;&lt;/strong&gt; in Info.plist is &lt;strong&gt;mandatory&lt;/strong&gt;. Without it, the first attempt to connect to a local IP triggers a "Local Network Access" alert, and nothing works until the user answers. Burned half an evening figuring this out the first time.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I went with the TCP connect approach via &lt;code&gt;withThrowingTaskGroup&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;maxConcurrentProbes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;

&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;discoverHosts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;localIP&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;subnetMask&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;AsyncThrowingStream&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;LANDevice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Error&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;AsyncThrowingStream&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;continuation&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
        &lt;span class="kt"&gt;Task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;detached&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;range&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;calculateSubnetRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;localIP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;subnetMask&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

            &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;withThrowingTaskGroup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;of&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;LANDevice&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;group&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
                &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;activeCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
                &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;range&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;activeCount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;maxConcurrentProbes&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;device&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;device&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;continuation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;yield&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
                        &lt;span class="p"&gt;}&lt;/span&gt;
                        &lt;span class="n"&gt;activeCount&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
                    &lt;span class="p"&gt;}&lt;/span&gt;
                    &lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;addTask&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;probeHost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="p"&gt;}&lt;/span&gt;
                    &lt;span class="n"&gt;activeCount&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;device&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;group&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;device&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;continuation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;yield&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="n"&gt;continuation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;finish&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One &lt;code&gt;connect()&lt;/code&gt; to port 80 with a 500ms timeout per IP across a /24 — the host responds with SYN+ACK (alive), RST (alive but port closed, also counts as alive), or times out (dead or filtered). With &lt;code&gt;maxConcurrentProbes = 20&lt;/code&gt; the whole /24 finishes in 2-3 seconds.&lt;/p&gt;

&lt;p&gt;iOS 17 gotcha: the "Local Network Access" alert shows only once. If the user denies it, subsequent &lt;code&gt;connect()&lt;/code&gt; calls just silently time out, with no clear error. Had to add a UI hint nudging the user to Settings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Host monitor: BGTaskScheduler as it is
&lt;/h2&gt;

&lt;p&gt;The most engineering-painful part of the app. The user-facing requirement: "I add hosts to monitor, and if one goes down I get a push, like UptimeRobot." On iOS you can't do this properly because:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No persistent background thread.&lt;/strong&gt; iOS kills your app ~30 seconds after backgrounding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silent push as a wake mechanism&lt;/strong&gt; exists but needs a server, and I wanted everything local.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;BGTaskScheduler&lt;/code&gt;&lt;/strong&gt; gives you ~30 sec of CPU &lt;strong&gt;maybe&lt;/strong&gt; once every 15+ minutes. iOS decides when based on user behavior patterns.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So monitoring precision on iOS is fundamentally worse than on a server. You have to accept that and design around it.&lt;/p&gt;

&lt;p&gt;What I do:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kt"&gt;BGTaskScheduler&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;forTaskWithIdentifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"com.netdiag.hostmonitor"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;using&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
    &lt;span class="kt"&gt;Task&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="kt"&gt;HostMonitorService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runMonitoringPass&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTaskCompleted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;submitNext&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;BGAppRefreshTaskRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"com.netdiag.hostmonitor"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;earliestBeginDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;timeIntervalSinceNow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="kt"&gt;BGTaskScheduler&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside &lt;code&gt;runMonitoringPass()&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Fetch all monitored hosts&lt;/li&gt;
&lt;li&gt;Ping them &lt;strong&gt;in parallel&lt;/strong&gt; (&lt;code&gt;withTaskGroup&lt;/code&gt;) — fit within the 30-second budget&lt;/li&gt;
&lt;li&gt;For each: if state changed (up→down or down→up), fire a local notification&lt;/li&gt;
&lt;li&gt;If state unchanged, stay quiet&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No "host still up every 5 minutes" pings. Only state transitions. This both helps with iOS background limits and respects the user.&lt;/p&gt;

&lt;p&gt;Debugging gotcha: &lt;strong&gt;BGTaskScheduler doesn't fire in the simulator&lt;/strong&gt; unless you manually trigger it via debugger:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight objective_c"&gt;&lt;code&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="k"&gt;-&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="n"&gt;objc&lt;/span&gt; &lt;span class="o"&gt;--&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;)[[&lt;/span&gt;&lt;span class="n"&gt;BGTaskScheduler&lt;/span&gt; &lt;span class="nf"&gt;sharedScheduler&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="nf"&gt;_simulateLaunchForTaskWithIdentifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s"&gt;@"com.netdiag.hostmonitor"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Found this in some old DTS thread. Without that command, debugging background logic is impossible. On real devices the system runs your tasks unpredictably.&lt;/p&gt;

&lt;h2&gt;
  
  
  AdMob and UMP — the part that actually hurt
&lt;/h2&gt;

&lt;p&gt;Networking was a pleasure to write. AdMob integration was a separate ordeal.&lt;/p&gt;

&lt;p&gt;AdMob via the &lt;code&gt;GoogleMobileAds&lt;/code&gt; Swift Package itself is fine — &lt;code&gt;MobileAds.shared.start()&lt;/code&gt;, banners through &lt;code&gt;BannerView&lt;/code&gt; in a &lt;code&gt;UIViewRepresentable&lt;/code&gt;, standard stuff.&lt;/p&gt;

&lt;p&gt;The pain begins with &lt;strong&gt;GDPR / CCPA consent&lt;/strong&gt;, which Google requires through their UMP SDK (&lt;code&gt;GoogleUserMessagingPlatform&lt;/code&gt;). Two problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;SwiftUI documentation&lt;/strong&gt; is essentially nonexistent. Every Google example is Objective-C or old UIKit. You figure out the SwiftUI integration by trial and error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AdMob won't activate your privacy message until you ship a "revocation link" in the live app&lt;/strong&gt; — that is, a button in Settings that re-opens the consent form. The business logic is: build the revocation link in code → &lt;strong&gt;ship that build&lt;/strong&gt; → then go back to AdMob and confirm "yes, my app has the revocation link, please activate the message." Nowhere is this clearly stated. I sat on a configured but inactive consent message for a week before figuring it out.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The integration looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;@MainActor&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="kt"&gt;ConsentManager&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;ObservableObject&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;shared&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;ConsentManager&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="kd"&gt;@Published&lt;/span&gt; &lt;span class="kd"&gt;private(set)&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;adsStarted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="kd"&gt;@Published&lt;/span&gt; &lt;span class="kd"&gt;private(set)&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;privacyOptionsRequired&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

    &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;requestConsentAndStartAds&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;parameters&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;RequestParameters&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="kt"&gt;ConsentInformation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestConsentInfoUpdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;with&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;weak&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
            &lt;span class="kt"&gt;ConsentForm&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loadAndPresentIfRequired&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;topViewController&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;weak&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;formError&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
                &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updatePrivacyOptionsAvailability&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startAdsIfNeeded&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;presentPrivacyOptionsForm&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;topViewController&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="kt"&gt;ConsentForm&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;presentPrivacyOptionsForm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;weak&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
            &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updatePrivacyOptionsAvailability&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;startAdsIfNeeded&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="kt"&gt;ConsentInformation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;canRequestAds&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="kt"&gt;MobileAds&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;weak&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
            &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;adsStarted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key part: &lt;code&gt;MobileAds.shared.start()&lt;/code&gt; is only called &lt;strong&gt;after&lt;/strong&gt; the UMP flow returns &lt;code&gt;canRequestAds == true&lt;/code&gt;. If a user in the EEA refuses, &lt;code&gt;canRequestAds&lt;/code&gt; stays true anyway, ads just become non-personalized. The same flow covers both GDPR (EEA/UK) and US state privacy (CCPA in California and other regulated states), because UMP under the hood inspects geo and serves the matching message.&lt;/p&gt;

&lt;p&gt;Another gotcha: &lt;code&gt;ATTrackingManager.requestTrackingAuthorization&lt;/code&gt; (the ATT prompt) &lt;strong&gt;must come before&lt;/strong&gt; the UMP flow, because UMP checks the ATT state when building the request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kt"&gt;ATTManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;requestTrackingIfNeeded&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;ConsentManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestConsentAndStartAds&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wrong order and UMP might think tracking is granted, then ATT denies, creating a mismatch AdMob can later flag.&lt;/p&gt;

&lt;p&gt;For debug builds I force the geography to test the flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="cp"&gt;#if DEBUG&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;debugSettings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;DebugSettings&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;debugSettings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;geography&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;EEA&lt;/span&gt;  &lt;span class="c1"&gt;// or .regulatedUSState&lt;/span&gt;
&lt;span class="n"&gt;parameters&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;debugSettings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;debugSettings&lt;/span&gt;
&lt;span class="cp"&gt;#endif&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without this, from a non-EEA IP the consent form never shows and you can't validate the flow at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Localization
&lt;/h2&gt;

&lt;p&gt;12 languages (en, ru, uk, de, es, pt-BR, fr, it, pl, ja, ko, tr) using the new Xcode &lt;strong&gt;String Catalogs&lt;/strong&gt; (&lt;code&gt;.xcstrings&lt;/code&gt;). After years of &lt;code&gt;Localizable.strings&lt;/code&gt;, the Catalog format is just nice — JSON, diffs reasonably in git, Xcode has a usable GUI for translation, automatic pluralization. If you're still on &lt;code&gt;.strings&lt;/code&gt;, migrate, you're losing nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Don't skimp on tests&lt;/strong&gt; for ICMP header parsing. I have decent tests on DNS resolver and whois parser, but I debugged the ICMP path against live networks. It worked for 30 hosts; then two days after release I found a bug with big-endian sequence numbers. Embarrassing.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Build UMP from day one&lt;/strong&gt;, before submitting to TestFlight. Retrofitting UMP after the fact rewrites your AdMob initialization order, ATT timing, etc. Just bake it in.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Don't trust the simulator&lt;/strong&gt; for anything touching background tasks, mDNS, or push notifications. I had several "green in simulator, dead on device" moments.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Numbers and context
&lt;/h2&gt;

&lt;p&gt;App went live a few weeks ago. ~30 total installs at the time of writing — basically zero marketing happened. This post is part of changing that.&lt;/p&gt;

&lt;p&gt;AdMob after the first week post-approval: $1.19 eCPM, 85% match rate — "normal for a utility at launch" according to indie folks I've asked.&lt;/p&gt;

&lt;p&gt;If you've done iOS network programming and hit different walls, I'd love to compare notes. Apple's networking stack isn't where most iOS devs spend their time and good references are scattered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;App Store: &lt;a href="https://apps.apple.com/app/id6761954529" rel="noopener noreferrer"&gt;https://apps.apple.com/app/id6761954529&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Apple's SimplePing sample (historical reference): &lt;a href="https://developer.apple.com/library/archive/samplecode/SimplePing/Introduction/Intro.html" rel="noopener noreferrer"&gt;https://developer.apple.com/library/archive/samplecode/SimplePing/Introduction/Intro.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;BGTaskScheduler docs: &lt;a href="https://developer.apple.com/documentation/backgroundtasks" rel="noopener noreferrer"&gt;https://developer.apple.com/documentation/backgroundtasks&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Google UMP SDK docs: &lt;a href="https://developers.google.com/admob/ios/privacy" rel="noopener noreferrer"&gt;https://developers.google.com/admob/ios/privacy&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;App is free with a banner, one-time IAP to remove ads. No accounts, no analytics beyond AdMob, no tracking SDK beyond what AdMob requires.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>swift</category>
      <category>networking</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
