c08fc277a9
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>
112 lines
4.3 KiB
Swift
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
|
|
}
|
|
}
|