Files
timban c08fc277a9 Initial commit: NetworkCity — network traffic as a neon city
A native macOS network monitor that renders live traffic as a top-down
neon city: your Mac is downtown, remote orgs are glowing districts, and
traffic is cars of light driving the roads.

Architecture:
- NetworkCityCore: swappable data layer behind a ConnectionSource protocol
  (nettop-backed today), plus pure/tested logic — traffic diffing, org
  classification (reverse-DNS + CIDR), traffic classes, hub-and-spoke
  expansion policy, label anti-overlap, and latency→distance layout.
- NetworkCityApp: SwiftUI + SpriteKit city — auto-expanding districts,
  protocol-coloured cars, click-to-inspect panel, and latency-as-distance
  (districts glide to their measured RTT).
- nettop-probe: CLI proof of the data layer.

44 tests passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 08:43:32 -07:00

112 lines
4.3 KiB
Swift

import Foundation
/// Pure, side-effect-free parsing of `nettop` CSV output. Kept separate from the
/// process-spawning so it can be unit-tested against captured fixtures.
///
/// Expected invocation: `nettop -L 1 -x -n -J time,bytes_in,bytes_out`
/// which yields lines like:
///
/// time,,bytes_in,bytes_out,
/// 23:49:56.574,apsd.571,15671580,11326466,
/// 23:49:56.573,tcp4 192.168.10.194:57359<->17.57.144.184:5223,15671580,11326466,
///
/// Process rows carry `Name.PID` in column 2; the connection sub-rows that follow
/// inherit that process until the next process row.
public enum NettopParser {
public static func parseConnections(_ raw: String) -> [Connection] {
var result: [Connection] = []
var currentProcess = "unknown"
var currentPID: Int32 = -1
for rawLine in raw.split(separator: "\n", omittingEmptySubsequences: true) {
// Don't omit empty subsequences: byte columns can legitimately be empty.
let fields = rawLine.split(separator: ",", omittingEmptySubsequences: false)
.map(String.init)
guard fields.count >= 2 else { continue }
let key = fields[1]
if key.isEmpty || fields[0] == "time" { continue } // header / blank
if key.contains("<->") {
if let conn = parseConnectionRow(
key: key,
bytesIn: fields.count > 2 ? fields[2] : "",
bytesOut: fields.count > 3 ? fields[3] : "",
processName: currentProcess,
pid: currentPID
) {
result.append(conn)
}
} else if let (name, pid) = parseProcessKey(key) {
currentProcess = name
currentPID = pid
}
}
return result
}
// MARK: - Rows
/// `mDNSResponder.647` -> ("mDNSResponder", 647). The suffix after the final
/// dot must be all digits, otherwise it's not a process header.
static func parseProcessKey(_ key: String) -> (String, Int32)? {
guard let dot = key.lastIndex(of: ".") else { return nil }
let pidPart = key[key.index(after: dot)...]
guard !pidPart.isEmpty, pidPart.allSatisfy(\.isNumber),
let pid = Int32(pidPart) else { return nil }
return (String(key[key.startIndex..<dot]), pid)
}
/// `tcp4 192.168.10.194:57359<->17.57.144.184:5223` -> Connection
static func parseConnectionRow(
key: String,
bytesIn: String,
bytesOut: String,
processName: String,
pid: Int32
) -> Connection? {
guard let space = key.firstIndex(of: " ") else { return nil }
let protoStr = String(key[key.startIndex..<space])
let tuple = String(key[key.index(after: space)...])
guard let arrow = tuple.range(of: "<->") else { return nil }
let localStr = String(tuple[tuple.startIndex..<arrow.lowerBound])
let remoteStr = String(tuple[arrow.upperBound...])
let isV6 = protoStr.hasSuffix("6")
return Connection(
proto: NetProtocol(protoStr),
local: parseEndpoint(localStr, isV6: isV6),
remote: parseEndpoint(remoteStr, isV6: isV6),
bytesIn: UInt64(bytesIn.trimmingCharacters(in: .whitespaces)) ?? 0,
bytesOut: UInt64(bytesOut.trimmingCharacters(in: .whitespaces)) ?? 0,
processName: processName,
pid: pid
)
}
// MARK: - Endpoints
/// IPv4 separates host/port with `:` (`1.2.3.4:443`); IPv6 uses `.`
/// (`::1.8021`) and may carry a `%zone` suffix on the host we strip off.
static func parseEndpoint(_ s: String, isV6: Bool) -> Endpoint {
let sep: Character = isV6 ? "." : ":"
guard let idx = s.lastIndex(of: sep) else {
return Endpoint(host: cleanHost(s), port: nil)
}
let host = cleanHost(String(s[s.startIndex..<idx]))
let portStr = String(s[s.index(after: idx)...])
let port = portStr == "*" ? nil : Int(portStr)
return Endpoint(host: host, port: port)
}
/// Strips an IPv6 scope id: `fe80::1%utun4` -> `fe80::1`.
static func cleanHost(_ h: String) -> String {
if let pct = h.firstIndex(of: "%") {
return String(h[h.startIndex..<pct])
}
return h
}
}