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.
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.
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.
Spoiler on conclusions: the most painful part wasn't networking, it was integrating Google's UMP consent SDK for AdMob.
ICMP ping without entitlements
The first surprise: on iOS, you can open an ICMP socket without any special entitlement and without root, which is something you'd need CAP_NET_RAW or setuid for on Linux. Apple exposed this to regular sandboxed processes through SOCK_DGRAM (not SOCK_RAW):
final class ICMPSocket {
private let fd: Int32
init() throws {
fd = socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP)
guard fd >= 0 else {
throw SocketError.creationFailed(errno)
}
}
deinit {
close(fd)
}
// ...
}
This is the same model macOS ping(8) 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.
The fix: match on sequence number and payload. I put my own marker in the payload and verify it on receive:
// Send
var header = ICMPHeader()
header.type = ICMPType.echoRequest.rawValue
header.code = 0
header.identifier = 0 // kernel will overwrite
header.sequence = currentSequence.bigEndian
let payload = makePayload(sequence: currentSequence)
let packet = header.bytes + payload
try socket.send(data: packet, to: address)
// Receive
let (data, fromIP) = try socket.receive()
guard let received = ICMPHeader.parse(data),
received.type == ICMPType.echoReply.rawValue,
received.sequence == currentSequence.bigEndian
else { continue }
Second gotcha: timeouts. recvfrom without SO_RCVTIMEO 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:
func setTimeout(seconds: Double) {
var tv = timeval(
tv_sec: Int(seconds),
tv_usec: Int32((seconds.truncatingRemainder(dividingBy: 1)) * 1_000_000)
)
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
}
On EAGAIN/EWOULDBLOCK I throw my own SocketError.timeout, which higher up in the stack becomes a normal "timeout" hop in the result list.
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.
Traceroute that actually works
Standard Unix traceroute has three flavors: ICMP echo (the Windows tracert style), UDP to closed ports (classic BSD), or TCP SYN (paris-traceroute, traceroute-tcp).
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:
-
setsockopt(IP_TTL)on a SOCK_DGRAM ICMP socket behaves inconsistently. Some networks worked, others wouldn't return Time Exceeded reliably. - Matching incoming responses. 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.
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:
let icmpSock = try ICMPSocket() // receive-only
icmpSock.setTimeout(seconds: timeout)
for ttl in 1...maxHops {
let udpSock = try UDPSocket()
udpSock.setTTL(ttl)
var dest = address
dest.sin_port = (port + UInt16(ttl - 1)).bigEndian // 33434, 33435, ...
let probe = Data(repeating: 0x40, count: 32)
let sendTime = CFAbsoluteTimeGetCurrent()
try udpSock.send(data: probe, to: dest)
let (data, fromIP) = try icmpSock.receive()
let rtt = CFAbsoluteTimeGetCurrent() - sendTime
// ...
}
The 33434+TTL port choice is traceroute(8)'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.
Key insight: UDP_TTL works fine on iOS via setsockopt(IP_TTL), unlike the ICMP variant. The ICMP socket is purely a passive receiver — we never send from it, only read incoming Time Exceeded packets.
Final hop detection is two-way:
let isFinal = fromIP == destIP
|| header.type == ICMPType.destinationUnreachable.rawValue
Either the response came from the destination IP (meaning it sent us Port Unreachable), or the ICMP type is Destination Unreachable.
LAN scanner: no ARP for you
This is where iOS cuts harder. You don't get the system ARP table in userspace. There's no getarp(), no /proc/net/arp like on Linux. So the obvious "read the ARP table and list neighbors" idea is dead on arrival.
What you do get:
- Concurrent TCP connect 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.
-
mDNS / Bonjour discovery via
NetServiceBrowserorNWBrowser. Partial coverage — only sees devices that advertise services. -
NSLocalNetworkUsageDescriptionin Info.plist is mandatory. 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.
I went with the TCP connect approach via withThrowingTaskGroup:
private static let maxConcurrentProbes = 20
func discoverHosts(localIP: String, subnetMask: String) -> AsyncThrowingStream<LANDevice, Error> {
AsyncThrowingStream { continuation in
Task.detached {
let range = Self.calculateSubnetRange(ip: localIP, mask: subnetMask)
try await withThrowingTaskGroup(of: LANDevice?.self) { group in
var activeCount = 0
for ip in range {
if activeCount >= Self.maxConcurrentProbes {
if let device = try? await group.next() {
if let d = device { continuation.yield(d) }
}
activeCount -= 1
}
group.addTask {
try await Self.probeHost(ip: ip)
}
activeCount += 1
}
for try await device in group {
if let d = device { continuation.yield(d) }
}
}
continuation.finish()
}
}
}
One connect() 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 maxConcurrentProbes = 20 the whole /24 finishes in 2-3 seconds.
iOS 17 gotcha: the "Local Network Access" alert shows only once. If the user denies it, subsequent connect() calls just silently time out, with no clear error. Had to add a UI hint nudging the user to Settings.
Host monitor: BGTaskScheduler as it is
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:
- No persistent background thread. iOS kills your app ~30 seconds after backgrounding.
- Silent push as a wake mechanism exists but needs a server, and I wanted everything local.
-
BGTaskSchedulergives you ~30 sec of CPU maybe once every 15+ minutes. iOS decides when based on user behavior patterns.
So monitoring precision on iOS is fundamentally worse than on a server. You have to accept that and design around it.
What I do:
BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.netdiag.hostmonitor", using: nil) { task in
Task {
await HostMonitorService.shared.runMonitoringPass()
task.setTaskCompleted(success: true)
}
submitNext()
}
let request = BGAppRefreshTaskRequest(identifier: "com.netdiag.hostmonitor")
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
try BGTaskScheduler.shared.submit(request)
Inside runMonitoringPass():
- Fetch all monitored hosts
- Ping them in parallel (
withTaskGroup) — fit within the 30-second budget - For each: if state changed (up→down or down→up), fire a local notification
- If state unchanged, stay quiet
No "host still up every 5 minutes" pings. Only state transitions. This both helps with iOS background limits and respects the user.
Debugging gotcha: BGTaskScheduler doesn't fire in the simulator unless you manually trigger it via debugger:
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.netdiag.hostmonitor"]
Found this in some old DTS thread. Without that command, debugging background logic is impossible. On real devices the system runs your tasks unpredictably.
AdMob and UMP — the part that actually hurt
Networking was a pleasure to write. AdMob integration was a separate ordeal.
AdMob via the GoogleMobileAds Swift Package itself is fine — MobileAds.shared.start(), banners through BannerView in a UIViewRepresentable, standard stuff.
The pain begins with GDPR / CCPA consent, which Google requires through their UMP SDK (GoogleUserMessagingPlatform). Two problems:
- SwiftUI documentation is essentially nonexistent. Every Google example is Objective-C or old UIKit. You figure out the SwiftUI integration by trial and error.
- AdMob won't activate your privacy message until you ship a "revocation link" in the live app — that is, a button in Settings that re-opens the consent form. The business logic is: build the revocation link in code → ship that build → 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.
The integration looks like this:
@MainActor
final class ConsentManager: ObservableObject {
static let shared = ConsentManager()
@Published private(set) var adsStarted = false
@Published private(set) var privacyOptionsRequired = false
func requestConsentAndStartAds() {
let parameters = RequestParameters()
ConsentInformation.shared.requestConsentInfoUpdate(with: parameters) { [weak self] error in
ConsentForm.loadAndPresentIfRequired(from: Self.topViewController()) { [weak self] formError in
self?.updatePrivacyOptionsAvailability()
self?.startAdsIfNeeded()
}
}
}
func presentPrivacyOptionsForm() {
guard let root = Self.topViewController() else { return }
ConsentForm.presentPrivacyOptionsForm(from: root) { [weak self] error in
self?.updatePrivacyOptionsAvailability()
}
}
private func startAdsIfNeeded() {
guard ConsentInformation.shared.canRequestAds else { return }
MobileAds.shared.start { [weak self] _ in
self?.adsStarted = true
}
}
// ...
}
The key part: MobileAds.shared.start() is only called after the UMP flow returns canRequestAds == true. If a user in the EEA refuses, canRequestAds 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.
Another gotcha: ATTrackingManager.requestTrackingAuthorization (the ATT prompt) must come before the UMP flow, because UMP checks the ATT state when building the request:
ATTManager.requestTrackingIfNeeded {
ConsentManager.shared.requestConsentAndStartAds()
}
Wrong order and UMP might think tracking is granted, then ATT denies, creating a mismatch AdMob can later flag.
For debug builds I force the geography to test the flow:
#if DEBUG
let debugSettings = DebugSettings()
debugSettings.geography = .EEA // or .regulatedUSState
parameters.debugSettings = debugSettings
#endif
Without this, from a non-EEA IP the consent form never shows and you can't validate the flow at all.
Localization
12 languages (en, ru, uk, de, es, pt-BR, fr, it, pl, ja, ko, tr) using the new Xcode String Catalogs (.xcstrings). After years of Localizable.strings, 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 .strings, migrate, you're losing nothing.
What I'd do differently
Don't skimp on tests 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.
Build UMP from day one, before submitting to TestFlight. Retrofitting UMP after the fact rewrites your AdMob initialization order, ATT timing, etc. Just bake it in.
Don't trust the simulator for anything touching background tasks, mDNS, or push notifications. I had several "green in simulator, dead on device" moments.
Numbers and context
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.
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.
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.
Links
- App Store: https://apps.apple.com/app/id6761954529
- Apple's SimplePing sample (historical reference): https://developer.apple.com/library/archive/samplecode/SimplePing/Introduction/Intro.html
- BGTaskScheduler docs: https://developer.apple.com/documentation/backgroundtasks
- Google UMP SDK docs: https://developers.google.com/admob/ios/privacy
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.
Top comments (0)