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>
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import Foundation
|
||||
|
||||
/// A `ConnectionSource` backed by repeatedly shelling out to `/usr/bin/nettop`.
|
||||
///
|
||||
/// Each tick runs a fresh one-shot `nettop` (`-L 1`). Because byte counts are
|
||||
/// lifetime-cumulative and stable across invocations, the consumer diffs
|
||||
/// successive snapshots to recover per-interval traffic. No elevated privileges
|
||||
/// required — that's the whole point of starting here.
|
||||
public final class NettopSource: ConnectionSource {
|
||||
|
||||
private let nettopPath: String
|
||||
|
||||
public init(nettopPath: String = "/usr/bin/nettop") {
|
||||
self.nettopPath = nettopPath
|
||||
}
|
||||
|
||||
public func snapshots(every interval: TimeInterval) -> AsyncStream<ConnectionSnapshot> {
|
||||
AsyncStream { continuation in
|
||||
let task = Task { [nettopPath] in
|
||||
while !Task.isCancelled {
|
||||
if let output = try? Self.runNettop(at: nettopPath) {
|
||||
let conns = NettopParser.parseConnections(output)
|
||||
continuation.yield(ConnectionSnapshot(timestamp: Date(), connections: conns))
|
||||
}
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
|
||||
} catch {
|
||||
break // cancelled
|
||||
}
|
||||
}
|
||||
continuation.finish()
|
||||
}
|
||||
continuation.onTermination = { _ in task.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs one-shot nettop and returns its stdout. Reads the pipe to EOF *before*
|
||||
/// waiting on exit so large output can't deadlock a full pipe buffer.
|
||||
static func runNettop(at path: String) throws -> String {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: path)
|
||||
// -L 1 : one sample then exit -x : raw bytes (no unit suffixes)
|
||||
// -n : numeric (skip DNS; we enrich ourselves)
|
||||
// -J : pick only the columns we parse
|
||||
process.arguments = ["-L", "1", "-x", "-n", "-J", "time,bytes_in,bytes_out"]
|
||||
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = FileHandle.nullDevice
|
||||
|
||||
try process.run()
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
process.waitUntilExit()
|
||||
|
||||
return String(decoding: data, as: UTF8.self)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user