DEV Community

aydinmrnv
aydinmrnv

Posted on

I built a network scanner for iOS in Swift 6 — here's what I learned

What it does
Netsight scans your local network, identifies every connected device, maps the topology, and gives you a toolkit for network diagnostics: ping, traceroute, port scanner, DNS lookup, Wake-on-LAN, SSH terminal, RTSP camera viewer, and a "Who's on WiFi" view that flags unidentified devices.

The scanning engine
The first problem: iOS doesn't let you send raw ICMP packets. There's no ping syscall you can call from a sandboxed app. So device discovery runs entirely on TCP connect probes via the Network framework's NWConnection:
swiftlet connection = NWConnection(host: .name(ip, nil), port: .init(integerLiteral: port), using: .tcp)
connection.stateUpdateHandler = { state in
switch state {
case .ready:
// Host is alive, RTT is the connection time
case .failed:
// Nothing responded
}
}
connection.start(queue: queue)
The downside: hosts with strict firewalls that block all TCP connections won't appear in the scan. We're working on adding ICMP and UDP fallback probes to handle that.

Device identification
Once a host is alive, we try to figure out what it is. The pipeline:

OUI lookup — the first 3 bytes of the MAC address identify the manufacturer. We ship a lookup table and resolve it async with an actor-based cache.
Bonjour/mDNS — NWBrowser browses 20+ service types simultaneously. _hap._tcp means HomeKit, _ssh._tcp means there's an SSH server, _raop._tcp means AirPlay, and so on.
HTTP fingerprinting — probe port 80/443 and parse the Server: header. An nginx server with a Proxmox web UI responds differently than a consumer router.
Hostname patterns — eero.lan, firewalla.inc.lan, synology.local all tell you something.

The result is a DeviceType enum with 20+ cases and a corresponding SF Symbol and color for each.

The MAC address problem
Here's one I didn't expect: you can't read the ARP table on iOS.
On Linux you'd just parse /proc/net/arp. On macOS you can call sysctl(NET_RT_FLAGS). On iOS, that sysctl is sandboxed and always returns empty. So macAddress is always nil, which means manufacturer lookup from OUI never runs, and the stable device identity key falls back to IP address (which changes on DHCP lease renewal).
Apps like Fing apparently work around this with a combination of UPnP UUID parsing and mDNS record analysis. We haven't cracked it yet.

Traceroute without raw sockets
Same story with traceroute. The classic approach — raw ICMP sockets with TTL manipulation — is off limits. But SOCK_DGRAM + IPPROTO_ICMP (the unprivileged datagram ICMP socket, same family Apple's SimplePing uses) works in the sandbox:
swiftlet fd = socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP)
var ttl = Int32(hop)
setsockopt(fd, IPPROTO_IP, IP_TTL, &ttl, socklen_t(MemoryLayout.size))
// connect + send ICMP Echo Request
// listen for Time Exceeded response
This lets us see intermediate hops. Routers that silently drop TTL-expired packets (common on mesh networks) show as *, which is the same behavior as standard traceroute on any platform.

Swift 6 strict concurrency
The whole codebase is Swift 6 with strict concurrency enabled. The main scanner is @MainActor @observable:
swift@MainActor
@observable
final class NetworkScanner {
var devices: [NetworkDevice] = []
var isScanning = false
var scanProgress: Double = 0
}
Concurrent probe tasks run in withTaskGroup and send results back to the main actor via a batched update queue — flushed every 100ms rather than per-host, so the UI doesn't get hammered during a fast scan on a big network.
Actor-based caches for OUI lookup, reverse DNS, and HTTP fingerprinting prevent redundant work during multi-pass stabilization scans.

The topology map
Visualizing the network as a graph turned out to be the hardest UI problem. The first attempt used a fixed ring layout — gateway at center, access points in a ring, leaf devices fanning out. It looked great on the demo network. On a real network with 4 mesh nodes and 20+ devices, it was unusable.
We're replacing it with a Fruchterman-Reingold force-directed layout. FR runs as a one-shot batch computation on a background thread — 100 iterations for 30 nodes takes about 5ms — and produces a settled layout in a single animated update. No continuous physics simulation, no jitter, and it handles any graph structure regardless of how accurately devices are classified.

RevenueCat for IAP
IAP is handled through RevenueCat rather than StoreKit directly. The paywall has three tiers — monthly, annual, and a non-consumable lifetime purchase. RevenueCat's entitlement system means the Pro check is a single boolean:
swiftvar isPro: Bool {
purchases.isEntitled(to: "Netsight Pro")
}
One thing worth knowing: the RevenueCat SDK crashes Release builds if you configure it with a Test Store API key. Took a while to debug that one.

What's next

Force-directed topology map
Fallback probe methods for firewall-hardened devices (ICMP, UDP)
Matter/HomeKit device detection via mDNS TXT record parsing
Connected AP (BSSID) detection

If you're building anything on iOS that touches networking or want to try the beta, TestFlight link is in my profile.

Top comments (0)