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,4 @@
|
|||||||
|
.build/
|
||||||
|
.swiftpm/
|
||||||
|
*.xcodeproj/xcuserdata/
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// swift-tools-version: 6.0
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "NetworkCity",
|
||||||
|
platforms: [.macOS(.v13)],
|
||||||
|
products: [
|
||||||
|
// The swappable data layer. The SpriteKit "city" will depend on this
|
||||||
|
// and never know whether the bytes came from nettop or libpcap.
|
||||||
|
.library(name: "NetworkCityCore", targets: ["NetworkCityCore"]),
|
||||||
|
// Phase-1 proof of concept: prints live per-connection traffic rates.
|
||||||
|
.executable(name: "nettop-probe", targets: ["nettop-probe"]),
|
||||||
|
// Phase-3: the SpriteKit city — SwiftUI window, traffic as cars.
|
||||||
|
.executable(name: "NetworkCityApp", targets: ["NetworkCityApp"]),
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.target(name: "NetworkCityCore"),
|
||||||
|
.executableTarget(
|
||||||
|
name: "nettop-probe",
|
||||||
|
dependencies: ["NetworkCityCore"]
|
||||||
|
),
|
||||||
|
.executableTarget(
|
||||||
|
name: "NetworkCityApp",
|
||||||
|
dependencies: ["NetworkCityCore"]
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "NetworkCityCoreTests",
|
||||||
|
dependencies: ["NetworkCityCore"]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# NetworkCity
|
||||||
|
|
||||||
|
A macOS network monitor that renders live traffic as a top-down **city map** —
|
||||||
|
remote hosts become buildings, and your traffic becomes cars driving to them
|
||||||
|
(SimCity-style). This repo currently contains **Phase 1: the proven data layer**.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
ConnectionSource (protocol) ← the seam: "where bytes come from"
|
||||||
|
│
|
||||||
|
├─ NettopSource ← Phase 1: shells out to /usr/bin/nettop (no privileges)
|
||||||
|
└─ (libpcap source) ← Phase 2: real packets (needs privilege / helper tool)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
ConnectionSnapshot ← list of Connection, with cumulative byte counts
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
SpriteKit "city" (Phase 3) ← buildings + cars; never knows the data source
|
||||||
|
```
|
||||||
|
|
||||||
|
The renderer depends only on `NetworkCityCore`, so the data backend can be
|
||||||
|
swapped without touching the visuals.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
- `Sources/NetworkCityCore/` — models, the `ConnectionSource` protocol, the
|
||||||
|
`nettop` parser (`NettopParser`, pure/testable) and poller (`NettopSource`).
|
||||||
|
- `Sources/nettop-probe/` — Phase-1 CLI proof of concept. Prints live KB/s.
|
||||||
|
- `Tests/` — parser tests pinned to real captured `nettop` output.
|
||||||
|
|
||||||
|
## Try it
|
||||||
|
|
||||||
|
```sh
|
||||||
|
swift test # parser tests against real fixtures
|
||||||
|
swift run nettop-probe # live traffic, busiest external flows as KB/s
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key design facts (learned from real output)
|
||||||
|
|
||||||
|
- **nettop format**: process rows are `Name.PID,bytes_in,bytes_out`; the
|
||||||
|
connection sub-rows beneath inherit that process. IPv4 ports use `:`, IPv6
|
||||||
|
uses `.` and may carry a `%zone` suffix.
|
||||||
|
- **Rates by diffing**: byte counts are lifetime-cumulative, so we keep the last
|
||||||
|
snapshot and subtract. Robust to nettop quirks; works across fresh invocations.
|
||||||
|
- **Process names are unreliable** — nettop truncates/mangles them (it reported
|
||||||
|
`claude` as `2.1.177`). The **pid is trustworthy**; enrich the real name via
|
||||||
|
`proc_pidpath`/`ps` keyed on pid. (Phase-2 polish.)
|
||||||
|
- **`isExternal`** filters listeners, loopback, and link-local so only real
|
||||||
|
off-box destinations become buildings.
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
1. ✅ Data layer + live probe (nettop, no privileges)
|
||||||
|
2. ✅ SpriteKit city — SwiftUI window, glowing buildings, cars of light
|
||||||
|
(`swift run NetworkCityApp`): cyan cars inbound = downloads, amber outbound
|
||||||
|
= uploads; deterministic building placement; drag to pan, scroll to zoom.
|
||||||
|
3. ✅ Enrichment — real process names (`proc_pidpath`), reverse-DNS + a curated
|
||||||
|
CIDR table so hosts group into named **districts** (Apple, Google, AWS,
|
||||||
|
Telegram, Local Network…). PTR is primary; the CIDR table catches the
|
||||||
|
no-PTR giants (all of `17.0.0.0/8` is Apple). Run with `NC_DEBUG=1` to log
|
||||||
|
each host→district resolution to stderr.
|
||||||
|
4. ✅ Hub-and-spoke — busy districts auto-expand into their endpoints
|
||||||
|
(`This Mac → OneDrive → its many servers`); cars route the full two-segment
|
||||||
|
path. Expansion is a pure, tested `ExpansionPolicy` (≥2 endpoints, top-K by
|
||||||
|
bytes, TTL tail to avoid flicker). A single client downloading from a CDN
|
||||||
|
pins to one IP, so fan-out is driven by apps that genuinely spread
|
||||||
|
connections (sync clients, browsers).
|
||||||
|
5. ✅ Traffic differentiation — cars coloured by protocol class (DNS gold,
|
||||||
|
HTTPS cyan, HTTP orange, QUIC violet, Other slate) since direction is already
|
||||||
|
read from motion. Behaviour reinforces it: DNS = tiny fast sparks, QUIC =
|
||||||
|
streaks, heavy flows = big slow "freight" comets. Classifier is a pure tested
|
||||||
|
`TrafficClass`; HUD shows a legend.
|
||||||
|
6. ✅ Label anti-overlap (`LayoutSolver`).
|
||||||
|
7. ✅ Click-to-inspect — click a district hub to open a live panel: total
|
||||||
|
down/up, every endpoint with its reverse-DNS name + per-endpoint rate +
|
||||||
|
protocol-colour dot, and the owning process(es). Click empty space or ✕ to
|
||||||
|
close. A pulsing ring marks the selection. Click vs drag is disambiguated by
|
||||||
|
movement distance.
|
||||||
|
8. ✅ Latency as distance — distance from downtown = measured round-trip time
|
||||||
|
(unprivileged TCP-connect probe, cached), not geography. GeoIP was rejected
|
||||||
|
because anycast makes it assert a physical fiction; latency is measured so it
|
||||||
|
can't lie. Districts glide to their true distance as probes complete; the
|
||||||
|
inspector shows per-endpoint ms. Pure `LatencyLayout` (log curve) is tested;
|
||||||
|
`LatencyProber` uses Network.framework. Our own pid is filtered out so we
|
||||||
|
don't render our own probes.
|
||||||
|
9. `MenuBarExtra` shell; optional libpcap source via privileged helper.
|
||||||
|
|
||||||
|
### Env flags (opt-in diagnostics)
|
||||||
|
- `NC_DEBUG=1` — log host→district resolution, per-tick top districts, and fan-outs to stderr.
|
||||||
|
- `NC_DEMO=1` — offline demo: feeds a synthetic 8-endpoint "OneDrive" through the
|
||||||
|
real scene so the hub-and-spoke fan-out runs without nettop.
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import SpriteKit
|
||||||
|
|
||||||
|
/// A single endpoint (one server IP) belonging to an org — a small glowing
|
||||||
|
/// satellite that appears when its district fans out.
|
||||||
|
final class EndpointNode: SKNode {
|
||||||
|
private let glow: SKSpriteNode
|
||||||
|
private let core: SKShapeNode
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
glow = SKSpriteNode(texture: Textures.softCircle)
|
||||||
|
core = SKShapeNode(circleOfRadius: 2.5)
|
||||||
|
super.init()
|
||||||
|
glow.color = Palette.building
|
||||||
|
glow.colorBlendFactor = 1
|
||||||
|
glow.blendMode = .add
|
||||||
|
glow.setScale(0.12)
|
||||||
|
glow.alpha = 0.7
|
||||||
|
addChild(glow)
|
||||||
|
core.fillColor = Palette.building
|
||||||
|
core.strokeColor = .white
|
||||||
|
core.lineWidth = 0.4
|
||||||
|
addChild(core)
|
||||||
|
}
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
func pulse() {
|
||||||
|
core.removeAction(forKey: "pulse")
|
||||||
|
core.run(.sequence([.scale(to: 1.8, duration: 0.12), .scale(to: 1.0, duration: 0.3)]), withKey: "pulse")
|
||||||
|
glow.removeAction(forKey: "flare")
|
||||||
|
glow.run(.sequence([.fadeAlpha(to: 1.0, duration: 0.12), .fadeAlpha(to: 0.7, duration: 0.4)]), withKey: "flare")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A destination *organization* rendered as a glowing district hub. When busy it
|
||||||
|
/// fans out into its known endpoints (hub-and-spoke); when quiet it collapses
|
||||||
|
/// back to a single labelled hub.
|
||||||
|
final class BuildingNode: SKNode {
|
||||||
|
private let glow: SKSpriteNode
|
||||||
|
private let core: SKShapeNode
|
||||||
|
private let title: SKLabelNode
|
||||||
|
private let subtitle: SKLabelNode
|
||||||
|
private var cumulative: Double = 0
|
||||||
|
|
||||||
|
private let spokeLayer = SKNode()
|
||||||
|
private let endpointLayer = SKNode()
|
||||||
|
private var endpointNodes: [String: EndpointNode] = [:]
|
||||||
|
private var knownHosts: Set<String> = []
|
||||||
|
private(set) var isExpanded = false
|
||||||
|
|
||||||
|
private let maxEndpoints = 20
|
||||||
|
private let fanSpread = 75.0 * .pi / 180 // half-arc the spokes spread over
|
||||||
|
private let spokeRadius: CGFloat = 82
|
||||||
|
|
||||||
|
init(title titleText: String, subtitle subtitleText: String) {
|
||||||
|
glow = SKSpriteNode(texture: Textures.softCircle)
|
||||||
|
core = SKShapeNode(circleOfRadius: 5)
|
||||||
|
title = SKLabelNode(text: titleText)
|
||||||
|
subtitle = SKLabelNode(text: subtitleText)
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
spokeLayer.zPosition = -1
|
||||||
|
addChild(spokeLayer)
|
||||||
|
addChild(endpointLayer)
|
||||||
|
|
||||||
|
glow.color = Palette.building
|
||||||
|
glow.colorBlendFactor = 1
|
||||||
|
glow.blendMode = .add
|
||||||
|
glow.setScale(0.30)
|
||||||
|
glow.alpha = 0.85
|
||||||
|
addChild(glow)
|
||||||
|
|
||||||
|
core.fillColor = Palette.building
|
||||||
|
core.strokeColor = .white
|
||||||
|
core.lineWidth = 0.5
|
||||||
|
core.glowWidth = 1
|
||||||
|
addChild(core)
|
||||||
|
|
||||||
|
title.fontName = "Menlo-Bold"
|
||||||
|
title.fontSize = 13
|
||||||
|
title.fontColor = .white
|
||||||
|
title.verticalAlignmentMode = .top
|
||||||
|
title.position = CGPoint(x: 0, y: -12)
|
||||||
|
addChild(title)
|
||||||
|
|
||||||
|
subtitle.fontName = "Menlo"
|
||||||
|
subtitle.fontSize = 10
|
||||||
|
subtitle.fontColor = Palette.building.withAlphaComponent(0.85)
|
||||||
|
subtitle.verticalAlignmentMode = .top
|
||||||
|
subtitle.position = CGPoint(x: 0, y: -27)
|
||||||
|
addChild(subtitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
func update(subtitle text: String) {
|
||||||
|
if subtitle.text != text { subtitle.text = text }
|
||||||
|
}
|
||||||
|
|
||||||
|
var knownHostCount: Int { knownHosts.count }
|
||||||
|
|
||||||
|
/// The label box (title + subtitle) relative to this node's center, used for
|
||||||
|
/// collision-aware placement so labels don't overlap their neighbours.
|
||||||
|
var labelFootprint: CGRect {
|
||||||
|
title.calculateAccumulatedFrame().union(subtitle.calculateAccumulatedFrame())
|
||||||
|
}
|
||||||
|
|
||||||
|
func note(activeHosts: [String]) { knownHosts.formUnion(activeHosts) }
|
||||||
|
|
||||||
|
/// React to a tick of traffic: grow toward a size set by total volume, and
|
||||||
|
/// pulse so the eye catches the activity.
|
||||||
|
func receive(bytes: Double) {
|
||||||
|
cumulative += bytes
|
||||||
|
let target = (0.30 + CGFloat(log10(cumulative + 1)) * 0.09).clamped(0.30, 1.05)
|
||||||
|
glow.removeAction(forKey: "resize")
|
||||||
|
glow.run(.scale(to: target, duration: 0.6), withKey: "resize")
|
||||||
|
|
||||||
|
core.removeAction(forKey: "pulse")
|
||||||
|
core.run(.sequence([
|
||||||
|
.scale(to: 1.9, duration: 0.12),
|
||||||
|
.scale(to: 1.0, duration: 0.35),
|
||||||
|
]), withKey: "pulse")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Expansion
|
||||||
|
|
||||||
|
/// The capped, stably-ordered list of endpoints we actually draw.
|
||||||
|
private var renderedHosts: [String] { knownHosts.sorted().prefix(maxEndpoints).map { $0 } }
|
||||||
|
|
||||||
|
/// Local offset of an endpoint, fanned outward (away from downtown at origin).
|
||||||
|
private func offset(forIndex i: Int, count: Int, host: String) -> CGPoint {
|
||||||
|
let outward = atan2(position.y, position.x)
|
||||||
|
let angle: CGFloat
|
||||||
|
if count <= 1 {
|
||||||
|
angle = outward
|
||||||
|
} else {
|
||||||
|
let t = CGFloat(i) / CGFloat(count - 1) // 0…1 across the arc
|
||||||
|
angle = outward - fanSpread + 2 * fanSpread * t
|
||||||
|
}
|
||||||
|
let depth = spokeRadius + CGFloat(stableHash(host) % 4) * 11
|
||||||
|
return CGPoint(x: cos(angle) * depth, y: sin(angle) * depth)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scene-space position of an endpoint (for routing cars along its spoke).
|
||||||
|
func worldPosition(forEndpoint host: String) -> CGPoint {
|
||||||
|
let hosts = renderedHosts
|
||||||
|
guard let i = hosts.firstIndex(of: host) else { return position }
|
||||||
|
let o = offset(forIndex: i, count: hosts.count, host: host)
|
||||||
|
return CGPoint(x: position.x + o.x, y: position.y + o.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setExpanded(_ on: Bool) {
|
||||||
|
if on {
|
||||||
|
if !isExpanded {
|
||||||
|
isExpanded = true
|
||||||
|
if ProcessInfo.processInfo.environment["NC_DEBUG"] != nil {
|
||||||
|
FileHandle.standardError.write(Data("[expand] \(title.text ?? "?") fanned out → \(knownHostCount) endpoints\n".utf8))
|
||||||
|
}
|
||||||
|
endpointLayer.alpha = 0
|
||||||
|
spokeLayer.alpha = 0
|
||||||
|
endpointLayer.run(.fadeIn(withDuration: 0.4))
|
||||||
|
spokeLayer.run(.fadeAlpha(to: 0.5, duration: 0.4))
|
||||||
|
}
|
||||||
|
layoutEndpoints()
|
||||||
|
} else if isExpanded {
|
||||||
|
isExpanded = false
|
||||||
|
endpointLayer.run(.fadeOut(withDuration: 0.35))
|
||||||
|
spokeLayer.run(.sequence([.fadeOut(withDuration: 0.35), .run { [weak self] in
|
||||||
|
self?.spokeLayer.removeAllChildren()
|
||||||
|
self?.endpointLayer.removeAllChildren()
|
||||||
|
self?.endpointNodes.removeAll()
|
||||||
|
}]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pulseEndpoint(_ host: String) {
|
||||||
|
endpointNodes[host]?.pulse()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func layoutEndpoints() {
|
||||||
|
let hosts = renderedHosts
|
||||||
|
spokeLayer.removeAllChildren()
|
||||||
|
let spokes = CGMutablePath()
|
||||||
|
|
||||||
|
for (i, host) in hosts.enumerated() {
|
||||||
|
let target = offset(forIndex: i, count: hosts.count, host: host)
|
||||||
|
let node = endpointNodes[host] ?? {
|
||||||
|
let n = EndpointNode()
|
||||||
|
n.position = .zero
|
||||||
|
n.alpha = 0
|
||||||
|
n.run(.fadeIn(withDuration: 0.3))
|
||||||
|
endpointLayer.addChild(n)
|
||||||
|
endpointNodes[host] = n
|
||||||
|
return n
|
||||||
|
}()
|
||||||
|
node.run(.move(to: target, duration: 0.3))
|
||||||
|
spokes.move(to: .zero)
|
||||||
|
spokes.addLine(to: target)
|
||||||
|
}
|
||||||
|
|
||||||
|
// drop endpoints that fell out of the capped set
|
||||||
|
for (host, node) in endpointNodes where !hosts.contains(host) {
|
||||||
|
node.removeFromParent()
|
||||||
|
endpointNodes[host] = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let line = SKShapeNode(path: spokes)
|
||||||
|
line.strokeColor = Palette.building.withAlphaComponent(0.4)
|
||||||
|
line.lineWidth = 1
|
||||||
|
spokeLayer.addChild(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
import SpriteKit
|
||||||
|
import NetworkCityCore
|
||||||
|
|
||||||
|
/// Traffic of one class to/from one endpoint in a single tick.
|
||||||
|
struct ClassFlow {
|
||||||
|
let cls: TrafficClass
|
||||||
|
let inBytes: UInt64
|
||||||
|
let outBytes: UInt64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One endpoint's traffic within a district for a single tick, split by class.
|
||||||
|
struct EndpointTraffic {
|
||||||
|
let host: String
|
||||||
|
let flows: [ClassFlow]
|
||||||
|
var inBytes: UInt64 { flows.reduce(0) { $0 + $1.inBytes } }
|
||||||
|
var outBytes: UInt64 { flows.reduce(0) { $0 + $1.outBytes } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One org's traffic for a single tick, ready to render as a district.
|
||||||
|
struct DistrictTraffic {
|
||||||
|
let key: String
|
||||||
|
let title: String
|
||||||
|
var subtitle: String
|
||||||
|
var inBytes: UInt64
|
||||||
|
var outBytes: UInt64
|
||||||
|
var endpoints: [EndpointTraffic]
|
||||||
|
var rttMs: Double? = nil // measured round-trip time → distance from downtown
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The city. Downtown (your Mac) glows at the origin; every org is a district
|
||||||
|
/// hub placed at a deterministic spot around it, connected by a road. Busy
|
||||||
|
/// districts fan out into their endpoints (hub-and-spoke); cars of light flow
|
||||||
|
/// the full path — cyan inbound for downloads, amber outbound for uploads.
|
||||||
|
final class CityScene: SKScene {
|
||||||
|
|
||||||
|
private let cam = SKCameraNode()
|
||||||
|
private let worldRadius: CGFloat = 540
|
||||||
|
private var buildings: [String: BuildingNode] = [:]
|
||||||
|
private var roads: [String: SKShapeNode] = [:]
|
||||||
|
private var latencyRadius: [String: CGFloat] = [:]
|
||||||
|
private var ringKey: String?
|
||||||
|
private let roadLayer = SKNode()
|
||||||
|
private let carLayer = SKNode()
|
||||||
|
private let buildingLayer = SKNode()
|
||||||
|
|
||||||
|
// Auto-expand: an org's spokes stay open for a few ticks after it last
|
||||||
|
// ranked among the busiest, giving a smooth tail instead of flicker.
|
||||||
|
private let expansion = ExpansionPolicy()
|
||||||
|
|
||||||
|
/// Number of districts currently on the map (for the HUD).
|
||||||
|
var buildingCount: Int { buildings.count }
|
||||||
|
|
||||||
|
/// Called with a district key when a hub is clicked, or nil when empty space
|
||||||
|
/// is clicked (deselect).
|
||||||
|
var onSelect: ((String?) -> Void)?
|
||||||
|
private let selectionRing = SKShapeNode(circleOfRadius: 24)
|
||||||
|
private var dragDistance: CGFloat = 0
|
||||||
|
|
||||||
|
override func didMove(to view: SKView) {
|
||||||
|
backgroundColor = Palette.background
|
||||||
|
scaleMode = .resizeFill
|
||||||
|
anchorPoint = CGPoint(x: 0.5, y: 0.5)
|
||||||
|
|
||||||
|
drawGrid()
|
||||||
|
addChild(roadLayer)
|
||||||
|
addChild(carLayer)
|
||||||
|
addChild(buildingLayer)
|
||||||
|
addDowntown()
|
||||||
|
|
||||||
|
selectionRing.strokeColor = .white
|
||||||
|
selectionRing.lineWidth = 2
|
||||||
|
selectionRing.glowWidth = 4
|
||||||
|
selectionRing.alpha = 0
|
||||||
|
selectionRing.zPosition = 40
|
||||||
|
addChild(selectionRing)
|
||||||
|
|
||||||
|
camera = cam
|
||||||
|
cam.setScale(1.25)
|
||||||
|
addChild(cam)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Selection
|
||||||
|
|
||||||
|
/// Rings the selected district (or fades the ring out when nil).
|
||||||
|
func highlight(key: String?) {
|
||||||
|
ringKey = key
|
||||||
|
selectionRing.removeAllActions()
|
||||||
|
if let key, let building = buildings[key] {
|
||||||
|
selectionRing.position = building.position
|
||||||
|
selectionRing.alpha = 1
|
||||||
|
selectionRing.run(.repeatForever(.sequence([
|
||||||
|
.scale(to: 1.18, duration: 0.7), .scale(to: 1.0, duration: 0.7),
|
||||||
|
])))
|
||||||
|
} else {
|
||||||
|
selectionRing.run(.fadeOut(withDuration: 0.2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nearest district hub within a (zoom-aware) radius of the point.
|
||||||
|
private func districtKey(at point: CGPoint) -> String? {
|
||||||
|
let radius = 30 * cam.xScale
|
||||||
|
var best: (key: String, dist: CGFloat)?
|
||||||
|
for (key, building) in buildings {
|
||||||
|
let d = hypot(point.x - building.position.x, point.y - building.position.y)
|
||||||
|
if d < radius, best == nil || d < best!.dist { best = (key, d) }
|
||||||
|
}
|
||||||
|
return best?.key
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Static scenery
|
||||||
|
|
||||||
|
private func drawGrid() {
|
||||||
|
let grid = SKNode()
|
||||||
|
let step: CGFloat = 80
|
||||||
|
let extent: CGFloat = 1400
|
||||||
|
let path = CGMutablePath()
|
||||||
|
var x = -extent
|
||||||
|
while x <= extent { path.move(to: CGPoint(x: x, y: -extent)); path.addLine(to: CGPoint(x: x, y: extent)); x += step }
|
||||||
|
var y = -extent
|
||||||
|
while y <= extent { path.move(to: CGPoint(x: -extent, y: y)); path.addLine(to: CGPoint(x: extent, y: y)); y += step }
|
||||||
|
let line = SKShapeNode(path: path)
|
||||||
|
line.strokeColor = Palette.grid.withAlphaComponent(0.18)
|
||||||
|
line.lineWidth = 1
|
||||||
|
grid.addChild(line)
|
||||||
|
grid.zPosition = -100
|
||||||
|
addChild(grid)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addDowntown() {
|
||||||
|
let glow = SKSpriteNode(texture: Textures.softCircle)
|
||||||
|
glow.color = Palette.hub
|
||||||
|
glow.colorBlendFactor = 1
|
||||||
|
glow.blendMode = .add
|
||||||
|
glow.setScale(1.1)
|
||||||
|
glow.zPosition = -10
|
||||||
|
glow.run(.repeatForever(.sequence([
|
||||||
|
.fadeAlpha(to: 0.6, duration: 1.8),
|
||||||
|
.fadeAlpha(to: 1.0, duration: 1.8),
|
||||||
|
])))
|
||||||
|
addChild(glow)
|
||||||
|
|
||||||
|
let core = SKShapeNode(circleOfRadius: 9)
|
||||||
|
core.fillColor = Palette.hub
|
||||||
|
core.strokeColor = .white
|
||||||
|
core.glowWidth = 2
|
||||||
|
addChild(core)
|
||||||
|
|
||||||
|
let label = SKLabelNode(text: "▸ this mac")
|
||||||
|
label.fontName = "Menlo-Bold"
|
||||||
|
label.fontSize = 13
|
||||||
|
label.fontColor = Palette.hub
|
||||||
|
label.verticalAlignmentMode = .top
|
||||||
|
label.position = CGPoint(x: 0, y: -16)
|
||||||
|
addChild(label)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Live data
|
||||||
|
|
||||||
|
/// Render a tick of per-org traffic: ensure each district exists, decide
|
||||||
|
/// which ones are busy enough to fan out, then dispatch cars accordingly.
|
||||||
|
func apply(_ districts: [DistrictTraffic]) {
|
||||||
|
// 1. Ensure hubs exist and learn their endpoints.
|
||||||
|
for t in districts {
|
||||||
|
let hub = building(forKey: t.key, title: t.title, subtitle: t.subtitle)
|
||||||
|
hub.note(activeHosts: t.endpoints.map(\.host))
|
||||||
|
hub.receive(bytes: Double(t.inBytes + t.outBytes))
|
||||||
|
if let rtt = t.rttMs { repositionToLatency(key: t.key, rttMs: rtt) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Decide which districts fan out. Evaluate *every* known district
|
||||||
|
// (not just this tick's active ones) so a TTL tail keeps idle hubs
|
||||||
|
// open. A district needs ≥2 endpoints to have anything to expand.
|
||||||
|
let bytesByKey = Dictionary(districts.map { ($0.key, $0.inBytes + $0.outBytes) },
|
||||||
|
uniquingKeysWith: +)
|
||||||
|
let entries = buildings.map { key, hub in
|
||||||
|
ExpansionPolicy.District(key: key, bytes: bytesByKey[key] ?? 0, endpointCount: hub.knownHostCount)
|
||||||
|
}
|
||||||
|
let expanded = expansion.update(entries)
|
||||||
|
|
||||||
|
if ProcessInfo.processInfo.environment["NC_DEBUG"] != nil {
|
||||||
|
let top = districts.sorted { ($0.inBytes + $0.outBytes) > ($1.inBytes + $1.outBytes) }.prefix(3)
|
||||||
|
.map { "\($0.title)=\(($0.inBytes + $0.outBytes) / 1024)KB/ep\(buildings[$0.key]?.knownHostCount ?? 0)/\(expanded.contains($0.key) ? "EXP" : "—")" }
|
||||||
|
.joined(separator: " ")
|
||||||
|
var classKB: [TrafficClass: UInt64] = [:]
|
||||||
|
for d in districts { for e in d.endpoints { for f in e.flows { classKB[f.cls, default: 0] += f.inBytes + f.outBytes } } }
|
||||||
|
let mix = TrafficClass.allCases.compactMap { c in classKB[c].map { "\(c.rawValue):\($0 / 1024)KB" } }.joined(separator: " ")
|
||||||
|
FileHandle.standardError.write(Data("[tick] \(top) | \(mix)\n".utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Apply expansion state to all hubs, then route this tick's cars.
|
||||||
|
for (key, hub) in buildings { hub.setExpanded(expanded.contains(key)) }
|
||||||
|
|
||||||
|
for t in districts {
|
||||||
|
guard let hub = buildings[t.key] else { continue }
|
||||||
|
if expanded.contains(t.key) {
|
||||||
|
// Per endpoint, per class: cars run the full Mac→hub→endpoint path.
|
||||||
|
for e in t.endpoints {
|
||||||
|
let dest = hub.worldPosition(forEndpoint: e.host)
|
||||||
|
for f in e.flows {
|
||||||
|
dispatchSpokeCars(carCount(f.inBytes), hub: hub.position, endpoint: dest, inbound: true, cls: f.cls, bytes: f.inBytes)
|
||||||
|
dispatchSpokeCars(carCount(f.outBytes), hub: hub.position, endpoint: dest, inbound: false, cls: f.cls, bytes: f.outBytes)
|
||||||
|
}
|
||||||
|
if e.inBytes + e.outBytes > 0 { hub.pulseEndpoint(e.host) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Collapsed: aggregate every endpoint's flows by class onto the hub.
|
||||||
|
var byClass: [TrafficClass: (UInt64, UInt64)] = [:]
|
||||||
|
for e in t.endpoints {
|
||||||
|
for f in e.flows {
|
||||||
|
let prior = byClass[f.cls] ?? (0, 0)
|
||||||
|
byClass[f.cls] = (prior.0 + f.inBytes, prior.1 + f.outBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (cls, bytes) in byClass {
|
||||||
|
dispatchDirectCars(carCount(bytes.0), to: hub.position, inbound: true, cls: cls, bytes: bytes.0)
|
||||||
|
dispatchDirectCars(carCount(bytes.1), to: hub.position, inbound: false, cls: cls, bytes: bytes.1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func carCount(_ bytes: UInt64) -> Int {
|
||||||
|
guard bytes > 0 else { return 0 }
|
||||||
|
return max(1, min(7, Int(Double(bytes) / 16_384))) // ~1 car / 16 KB, capped
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Buildings & roads
|
||||||
|
|
||||||
|
private func building(forKey key: String, title: String, subtitle: String) -> BuildingNode {
|
||||||
|
if let existing = buildings[key] {
|
||||||
|
existing.update(subtitle: subtitle)
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
let node = BuildingNode(title: title, subtitle: subtitle)
|
||||||
|
node.position = placement(for: key, footprint: node.labelFootprint)
|
||||||
|
node.alpha = 0
|
||||||
|
node.run(.fadeIn(withDuration: 0.6))
|
||||||
|
buildingLayer.addChild(node)
|
||||||
|
buildings[key] = node
|
||||||
|
|
||||||
|
if ProcessInfo.processInfo.environment["NC_DEBUG"] != nil {
|
||||||
|
let f = node.labelFootprint
|
||||||
|
FileHandle.standardError.write(Data(String(format: "[place] %@ pos=(%.0f,%.0f) box=%.0fx%.0f\n",
|
||||||
|
title, node.position.x, node.position.y, f.width, f.height).utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = CGMutablePath()
|
||||||
|
path.move(to: .zero)
|
||||||
|
path.addLine(to: node.position)
|
||||||
|
let road = SKShapeNode(path: path)
|
||||||
|
road.strokeColor = Palette.road.withAlphaComponent(0.55)
|
||||||
|
road.lineWidth = 1.5
|
||||||
|
road.zPosition = -20
|
||||||
|
roadLayer.addChild(road)
|
||||||
|
roads[key] = road
|
||||||
|
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Glide a hub to the radius implied by its measured RTT, keeping its angle.
|
||||||
|
/// The road (and selection ring, if attached) follow.
|
||||||
|
private func repositionToLatency(key: String, rttMs: Double) {
|
||||||
|
guard let building = buildings[key] else { return }
|
||||||
|
let target = LatencyLayout.radius(forRTT: rttMs)
|
||||||
|
if let current = latencyRadius[key], abs(current - target) < 18 { return }
|
||||||
|
latencyRadius[key] = target
|
||||||
|
|
||||||
|
let angle = atan2(building.position.y, building.position.x)
|
||||||
|
let dest = CGPoint(x: cos(angle) * Double(target), y: sin(angle) * Double(target))
|
||||||
|
building.run(.move(to: dest, duration: 0.8))
|
||||||
|
|
||||||
|
if let road = roads[key] {
|
||||||
|
let path = CGMutablePath()
|
||||||
|
path.move(to: .zero)
|
||||||
|
path.addLine(to: dest)
|
||||||
|
road.path = path
|
||||||
|
}
|
||||||
|
if ringKey == key { selectionRing.run(.move(to: dest, duration: 0.8)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deterministic radial seed (angle + ring from the key's hash), then spiral
|
||||||
|
/// out from there until the label box clears every existing district.
|
||||||
|
private func placement(for key: String, footprint: CGRect) -> CGPoint {
|
||||||
|
let h = stableHash(key)
|
||||||
|
let baseAngle = Double(h % 3600) / 3600 * 2 * .pi
|
||||||
|
let ring = Double((h >> 16) % 1000) / 1000
|
||||||
|
let baseRadius = 165 + Double(ring) * Double(worldRadius - 165)
|
||||||
|
|
||||||
|
let existing = buildings.values.map {
|
||||||
|
$0.labelFootprint.offsetBy(dx: $0.position.x, dy: $0.position.y)
|
||||||
|
}
|
||||||
|
return LayoutSolver.placeNonOverlapping(
|
||||||
|
baseAngle: baseAngle, baseRadius: baseRadius,
|
||||||
|
localRect: footprint, existing: existing, pad: 18
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cars
|
||||||
|
|
||||||
|
private func dispatchDirectCars(_ count: Int, to dest: CGPoint, inbound: Bool, cls: TrafficClass, bytes: UInt64) {
|
||||||
|
guard count > 0 else { return }
|
||||||
|
let style = carStyle(cls, bytes: bytes)
|
||||||
|
stagger(count) { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
let path = CGMutablePath()
|
||||||
|
path.move(to: inbound ? dest : .zero)
|
||||||
|
path.addLine(to: inbound ? .zero : dest)
|
||||||
|
self.runCar(along: path, start: inbound ? dest : .zero, distance: hypot(dest.x, dest.y), style: style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dispatchSpokeCars(_ count: Int, hub: CGPoint, endpoint: CGPoint, inbound: Bool, cls: TrafficClass, bytes: UInt64) {
|
||||||
|
guard count > 0 else { return }
|
||||||
|
let style = carStyle(cls, bytes: bytes)
|
||||||
|
stagger(count) { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
let path = CGMutablePath()
|
||||||
|
if inbound {
|
||||||
|
path.move(to: endpoint); path.addLine(to: hub); path.addLine(to: .zero)
|
||||||
|
} else {
|
||||||
|
path.move(to: .zero); path.addLine(to: hub); path.addLine(to: endpoint)
|
||||||
|
}
|
||||||
|
let dist = hypot(hub.x, hub.y) + hypot(endpoint.x - hub.x, endpoint.y - hub.y)
|
||||||
|
self.runCar(along: path, start: inbound ? endpoint : .zero, distance: dist, style: style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spreads `count` spawns across most of the interval so cars stream rather
|
||||||
|
/// than appear all at once.
|
||||||
|
private func stagger(_ count: Int, _ spawn: @escaping () -> Void) {
|
||||||
|
guard count > 0 else { return }
|
||||||
|
for i in 0..<count {
|
||||||
|
let delay = Double(i) / Double(count) * 1.4
|
||||||
|
run(.sequence([.wait(forDuration: delay), .run(spawn)]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runCar(along path: CGPath, start: CGPoint, distance: CGFloat, style: CarStyle) {
|
||||||
|
let car = SKSpriteNode(texture: Textures.softCircle)
|
||||||
|
car.color = style.color
|
||||||
|
car.colorBlendFactor = 1
|
||||||
|
car.blendMode = .add
|
||||||
|
car.xScale = style.scale * style.elongation
|
||||||
|
car.yScale = style.scale
|
||||||
|
car.position = start
|
||||||
|
car.zPosition = 5
|
||||||
|
carLayer.addChild(car)
|
||||||
|
|
||||||
|
let orient = style.elongation > 1.05 // streaks orient along their path
|
||||||
|
let duration = Double((distance / style.speed).clamped(0.6, 2.8)) * Double.random(in: 0.85...1.15)
|
||||||
|
car.run(.sequence([
|
||||||
|
.group([
|
||||||
|
.follow(path, asOffset: false, orientToPath: orient, duration: duration),
|
||||||
|
.sequence([.fadeAlpha(to: 1.0, duration: duration * 0.15),
|
||||||
|
.wait(forDuration: duration * 0.55),
|
||||||
|
.fadeAlpha(to: 0.0, duration: duration * 0.3)]),
|
||||||
|
]),
|
||||||
|
.removeFromParent(),
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Pan, zoom & click (macOS)
|
||||||
|
|
||||||
|
override func scrollWheel(with event: NSEvent) {
|
||||||
|
let factor = 1 - event.scrollingDeltaY * 0.006
|
||||||
|
cam.setScale((cam.xScale * factor).clamped(0.45, 3.2))
|
||||||
|
}
|
||||||
|
|
||||||
|
override func mouseDown(with event: NSEvent) {
|
||||||
|
dragDistance = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override func mouseDragged(with event: NSEvent) {
|
||||||
|
dragDistance += abs(event.deltaX) + abs(event.deltaY)
|
||||||
|
cam.position.x -= event.deltaX * cam.xScale
|
||||||
|
cam.position.y += event.deltaY * cam.xScale
|
||||||
|
}
|
||||||
|
|
||||||
|
override func mouseUp(with event: NSEvent) {
|
||||||
|
// A near-stationary press is a click (select); a drag is a pan.
|
||||||
|
guard dragDistance < 6, let view else { return }
|
||||||
|
// Canonical, camera-correct window → view → scene conversion.
|
||||||
|
let viewPoint = view.convert(event.locationInWindow, from: nil)
|
||||||
|
onSelect?(districtKey(at: convertPoint(fromView: viewPoint)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SpriteKit
|
||||||
|
import NetworkCityCore
|
||||||
|
|
||||||
|
/// The SpriteKit city with a glassy stats HUD floated on top.
|
||||||
|
struct CityView: View {
|
||||||
|
@ObservedObject var controller: TrafficController
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
|
SpriteView(scene: controller.scene, options: [.ignoresSiblingOrder])
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
hud
|
||||||
|
.padding(14)
|
||||||
|
|
||||||
|
if let inspection = controller.selection {
|
||||||
|
inspector(inspection)
|
||||||
|
.padding(14)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
|
||||||
|
.transition(.move(edge: .trailing).combined(with: .opacity))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.easeOut(duration: 0.18), value: controller.selection?.id)
|
||||||
|
.onAppear { controller.start() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Inspector panel
|
||||||
|
|
||||||
|
static func color(_ c: TrafficClass) -> Color {
|
||||||
|
switch c {
|
||||||
|
case .dns: return Color(red: 1.00, green: 0.82, blue: 0.25)
|
||||||
|
case .https: return Color(red: 0.22, green: 0.88, blue: 1.00)
|
||||||
|
case .http: return Color(red: 1.00, green: 0.48, blue: 0.24)
|
||||||
|
case .quic: return Color(red: 0.71, green: 0.48, blue: 1.00)
|
||||||
|
case .other: return Color(red: 0.55, green: 0.60, blue: 0.72)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func inspector(_ insp: OrgInspection) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack {
|
||||||
|
Text(insp.title)
|
||||||
|
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
Spacer(minLength: 12)
|
||||||
|
Button { controller.deselect() } label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.foregroundStyle(.white.opacity(0.5))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
Text("▼ \(fmt(insp.downKBs))").foregroundStyle(.cyan)
|
||||||
|
Text("▲ \(fmt(insp.upKBs))").foregroundStyle(.orange)
|
||||||
|
Spacer(minLength: 4)
|
||||||
|
Text("\(insp.endpointCount) pts").foregroundStyle(.white.opacity(0.6))
|
||||||
|
}
|
||||||
|
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
||||||
|
|
||||||
|
if !insp.processes.isEmpty {
|
||||||
|
Text(insp.processes.joined(separator: ", "))
|
||||||
|
.font(.system(size: 10, design: .monospaced))
|
||||||
|
.foregroundStyle(.white.opacity(0.55))
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider().overlay(.white.opacity(0.15))
|
||||||
|
|
||||||
|
Text("ENDPOINTS").font(.system(size: 9, weight: .bold, design: .monospaced))
|
||||||
|
.foregroundStyle(.white.opacity(0.4))
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 7) {
|
||||||
|
ForEach(insp.endpoints) { ep in endpointRow(ep) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxHeight: 320)
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.frame(width: 290)
|
||||||
|
.background(.black.opacity(0.62), in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 12).stroke(.white.opacity(0.1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func endpointRow(_ ep: EndpointInfo) -> some View {
|
||||||
|
HStack(alignment: .top, spacing: 8) {
|
||||||
|
Circle().fill(Self.color(ep.topClass)).frame(width: 7, height: 7)
|
||||||
|
.shadow(color: Self.color(ep.topClass), radius: 3)
|
||||||
|
.padding(.top, 3)
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(ep.host)
|
||||||
|
.font(.system(size: 11, design: .monospaced))
|
||||||
|
.foregroundStyle(.white.opacity(0.9))
|
||||||
|
if let rdns = ep.rdns {
|
||||||
|
Text(rdns)
|
||||||
|
.font(.system(size: 9, design: .monospaced))
|
||||||
|
.foregroundStyle(.white.opacity(0.45))
|
||||||
|
.lineLimit(1).truncationMode(.middle)
|
||||||
|
}
|
||||||
|
if let rtt = ep.rttMs {
|
||||||
|
Text("\(Int(rtt)) ms")
|
||||||
|
.font(.system(size: 9, design: .monospaced))
|
||||||
|
.foregroundStyle(.white.opacity(0.5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 6)
|
||||||
|
VStack(alignment: .trailing, spacing: 1) {
|
||||||
|
if ep.downKBs >= 0.1 { Text("▼\(fmt(ep.downKBs))").foregroundStyle(.cyan.opacity(0.85)) }
|
||||||
|
if ep.upKBs >= 0.1 { Text("▲\(fmt(ep.upKBs))").foregroundStyle(.orange.opacity(0.85)) }
|
||||||
|
if ep.downKBs < 0.1 && ep.upKBs < 0.1 { Text("idle").foregroundStyle(.white.opacity(0.3)) }
|
||||||
|
}
|
||||||
|
.font(.system(size: 9, weight: .semibold, design: .monospaced))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hud: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Circle()
|
||||||
|
.fill(controller.live ? Color.green : Color.orange)
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
Text("NETWORKCITY")
|
||||||
|
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
||||||
|
.foregroundStyle(.white.opacity(0.9))
|
||||||
|
}
|
||||||
|
Divider().frame(width: 168).overlay(.white.opacity(0.15))
|
||||||
|
stat("▼ down", controller.downKBs, .cyan)
|
||||||
|
stat("▲ up", controller.upKBs, .orange)
|
||||||
|
stat(label: "districts", value: "\(controller.districtCount)")
|
||||||
|
|
||||||
|
Divider().frame(width: 168).overlay(.white.opacity(0.15))
|
||||||
|
legend
|
||||||
|
|
||||||
|
Text(controller.live ? "live · color = protocol · motion = direction"
|
||||||
|
: "warming up…")
|
||||||
|
.font(.system(size: 10, design: .monospaced))
|
||||||
|
.foregroundStyle(.white.opacity(0.4))
|
||||||
|
.padding(.top, 2)
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(.black.opacity(0.45), in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 10).stroke(.white.opacity(0.08)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let legendItems: [(String, Color)] = [
|
||||||
|
("DNS", Color(red: 1.00, green: 0.82, blue: 0.25)),
|
||||||
|
("HTTPS", Color(red: 0.22, green: 0.88, blue: 1.00)),
|
||||||
|
("HTTP", Color(red: 1.00, green: 0.48, blue: 0.24)),
|
||||||
|
("QUIC", Color(red: 0.71, green: 0.48, blue: 1.00)),
|
||||||
|
("Other", Color(red: 0.55, green: 0.60, blue: 0.72)),
|
||||||
|
]
|
||||||
|
|
||||||
|
private var legend: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
ForEach(Self.legendItems, id: \.0) { name, color in
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Circle().fill(color).frame(width: 7, height: 7)
|
||||||
|
.shadow(color: color, radius: 3)
|
||||||
|
Text(name)
|
||||||
|
.font(.system(size: 11, design: .monospaced))
|
||||||
|
.foregroundStyle(.white.opacity(0.8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 168, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stat(_ label: String, _ kbs: Double, _ color: Color) -> some View {
|
||||||
|
HStack {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 12, design: .monospaced))
|
||||||
|
.foregroundStyle(color.opacity(0.9))
|
||||||
|
Spacer(minLength: 16)
|
||||||
|
Text(format(kbs))
|
||||||
|
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
.frame(width: 168)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stat(label: String, value: String) -> some View {
|
||||||
|
HStack {
|
||||||
|
Text(label).font(.system(size: 12, design: .monospaced)).foregroundStyle(.white.opacity(0.7))
|
||||||
|
Spacer(minLength: 16)
|
||||||
|
Text(value).font(.system(size: 12, weight: .semibold, design: .monospaced)).foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
.frame(width: 168)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func format(_ kbs: Double) -> String {
|
||||||
|
kbs >= 1024 ? String(format: "%.1f MB/s", kbs / 1024) : String(format: "%.0f KB/s", kbs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact rate for the dense inspector rows.
|
||||||
|
private func fmt(_ kbs: Double) -> String {
|
||||||
|
kbs >= 1024 ? String(format: "%.1fM", kbs / 1024) : String(format: "%.0fK", kbs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import NetworkCityCore
|
||||||
|
|
||||||
|
/// One endpoint's live detail for the inspector panel.
|
||||||
|
struct EndpointInfo: Identifiable {
|
||||||
|
var id: String { host }
|
||||||
|
let host: String
|
||||||
|
let rdns: String?
|
||||||
|
let downKBs: Double
|
||||||
|
let upKBs: Double
|
||||||
|
let topClass: TrafficClass
|
||||||
|
let rttMs: Double?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A live snapshot of one district, shown when its hub is clicked. Rebuilt each
|
||||||
|
/// tick while selected so the panel updates in real time.
|
||||||
|
struct OrgInspection: Identifiable {
|
||||||
|
var id: String { key }
|
||||||
|
let key: String
|
||||||
|
let title: String
|
||||||
|
let downKBs: Double
|
||||||
|
let upKBs: Double
|
||||||
|
let endpointCount: Int
|
||||||
|
let endpoints: [EndpointInfo]
|
||||||
|
let processes: [String]
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct NetworkCityApp: App {
|
||||||
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
|
||||||
|
@StateObject private var controller = TrafficController()
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup("NetworkCity") {
|
||||||
|
CityView(controller: controller)
|
||||||
|
.frame(minWidth: 900, minHeight: 620)
|
||||||
|
.background(.black)
|
||||||
|
}
|
||||||
|
.windowStyle(.hiddenTitleBar)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Running as an SPM executable (no app bundle), so promote ourselves to a
|
||||||
|
/// regular foreground app and grab focus on launch.
|
||||||
|
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
|
NSApp.setActivationPolicy(.regular)
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
}
|
||||||
|
func applicationShouldTerminateAfterLastWindowClosed(_ app: NSApplication) -> Bool { true }
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import NetworkCityCore
|
||||||
|
|
||||||
|
/// Owns the data feed, enriches it (real process names + reverse-DNS org
|
||||||
|
/// grouping), and bridges per-district traffic to the scene on the main actor.
|
||||||
|
@MainActor
|
||||||
|
final class TrafficController: ObservableObject {
|
||||||
|
let scene = CityScene(size: CGSize(width: 1200, height: 800))
|
||||||
|
|
||||||
|
@Published var downKBs: Double = 0
|
||||||
|
@Published var upKBs: Double = 0
|
||||||
|
@Published var districtCount: Int = 0
|
||||||
|
@Published var live = false
|
||||||
|
@Published var selection: OrgInspection?
|
||||||
|
|
||||||
|
private let source: ConnectionSource = NettopSource()
|
||||||
|
private let differ = TrafficDiffer()
|
||||||
|
private let namer = ProcessNamer()
|
||||||
|
private let prober = LatencyProber()
|
||||||
|
private let ownPID = Int32(ProcessInfo.processInfo.processIdentifier)
|
||||||
|
|
||||||
|
private var orgForHost: [String: Org] = [:] // resolved IP -> org
|
||||||
|
private var pending: Set<String> = [] // resolutions in flight
|
||||||
|
private var rttForHost: [String: Double] = [:] // measured RTT (ms)
|
||||||
|
private var rttPending: Set<String> = []
|
||||||
|
private var portForHost: [String: UInt16] = [:]
|
||||||
|
private var task: Task<Void, Never>?
|
||||||
|
|
||||||
|
// Inspector state: enough per-org history to render the panel live.
|
||||||
|
private var selectedKey: String?
|
||||||
|
private var orgTitles: [String: String] = [:]
|
||||||
|
private var orgEndpoints: [String: Set<String>] = [:] // all hosts ever seen per org
|
||||||
|
private var orgProcesses: [String: Set<String>] = [:]
|
||||||
|
private var lastEndpointBytes: [String: [String: [TrafficClass: (UInt64, UInt64)]]] = [:]
|
||||||
|
private var lastInterval: TimeInterval = 2
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard task == nil else { return }
|
||||||
|
scene.onSelect = { [weak self] key in
|
||||||
|
MainActor.assumeIsolated { self?.select(key) }
|
||||||
|
}
|
||||||
|
if ProcessInfo.processInfo.environment["NC_DEMO"] != nil {
|
||||||
|
startDemo()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
task = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
for await snap in source.snapshots(every: 2.0) {
|
||||||
|
handle(snap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Offline demo: feeds a synthetic multi-endpoint district through the real
|
||||||
|
/// scene path so the hub-and-spoke fan-out can be exercised without nettop.
|
||||||
|
private func startDemo() {
|
||||||
|
task = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
var tick = 0
|
||||||
|
while !Task.isCancelled {
|
||||||
|
tick += 1
|
||||||
|
let classes: [TrafficClass] = [.https, .quic, .dns, .http, .other]
|
||||||
|
let endpoints = (0..<8).map { i -> EndpointTraffic in
|
||||||
|
let active = (i + tick) % 3 == 0
|
||||||
|
let cls = classes[i % classes.count]
|
||||||
|
let heavy = (cls == .https || cls == .quic) && i % 4 == 0
|
||||||
|
return EndpointTraffic(host: "20.190.\(i).\(10 + i)", flows: [
|
||||||
|
ClassFlow(cls: cls,
|
||||||
|
inBytes: active ? (heavy ? 400_000 : 36_000) : 0,
|
||||||
|
outBytes: active ? 12_000 : 0),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
let inB = endpoints.reduce(0) { $0 + $1.inBytes }
|
||||||
|
let outB = endpoints.reduce(0) { $0 + $1.outBytes }
|
||||||
|
scene.apply([DistrictTraffic(key: "onedrive", title: "OneDrive", subtitle: "OneDrive",
|
||||||
|
inBytes: inB, outBytes: outB, endpoints: endpoints)])
|
||||||
|
|
||||||
|
// Keep the inspector maps live so the demo district is clickable.
|
||||||
|
orgTitles["onedrive"] = "OneDrive"
|
||||||
|
orgEndpoints["onedrive", default: []].formUnion(endpoints.map(\.host))
|
||||||
|
orgProcesses["onedrive", default: []].insert("OneDrive")
|
||||||
|
var bytesMap: [String: [TrafficClass: (UInt64, UInt64)]] = [:]
|
||||||
|
for e in endpoints { for f in e.flows { bytesMap[e.host, default: [:]][f.cls] = (f.inBytes, f.outBytes) } }
|
||||||
|
lastEndpointBytes["onedrive"] = bytesMap
|
||||||
|
if let key = selectedKey { selection = buildInspection(key) }
|
||||||
|
// Verification hook: auto-open the inspector in debug runs.
|
||||||
|
if tick == 3, ProcessInfo.processInfo.environment["NC_DEBUG"] != nil { select("onedrive") }
|
||||||
|
|
||||||
|
downKBs = Double(inB) / 1024 / 2
|
||||||
|
upKBs = Double(outB) / 1024 / 2
|
||||||
|
districtCount = scene.buildingCount
|
||||||
|
live = true
|
||||||
|
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() { task?.cancel(); task = nil }
|
||||||
|
|
||||||
|
private func handle(_ snapshot: ConnectionSnapshot) {
|
||||||
|
// Exclude our own traffic so we don't render our latency probes.
|
||||||
|
let deltas = differ.ingest(snapshot).filter { $0.connection.isExternal && $0.connection.pid != ownPID }
|
||||||
|
|
||||||
|
// Group resolved hosts by org; kick off resolution for the rest. A
|
||||||
|
// brand-new host shows up one tick late, which is fine — traffic is
|
||||||
|
// continuous, and this avoids spawning an IP building we'd have to merge.
|
||||||
|
var districts: [String: DistrictTraffic] = [:]
|
||||||
|
// orgKey -> host -> class -> (in, out)
|
||||||
|
var endpoints: [String: [String: [TrafficClass: (UInt64, UInt64)]]] = [:]
|
||||||
|
for d in deltas {
|
||||||
|
let host = d.connection.remote.host
|
||||||
|
guard let org = orgForHost[host] else { resolve(host); continue }
|
||||||
|
|
||||||
|
let proc = namer.name(for: d.connection.pid) ?? d.connection.processName
|
||||||
|
var district = districts[org.key]
|
||||||
|
?? DistrictTraffic(key: org.key, title: org.name, subtitle: proc,
|
||||||
|
inBytes: 0, outBytes: 0, endpoints: [])
|
||||||
|
district.inBytes += d.bytesInDelta
|
||||||
|
district.outBytes += d.bytesOutDelta
|
||||||
|
district.subtitle = proc
|
||||||
|
districts[org.key] = district
|
||||||
|
|
||||||
|
let cls = TrafficClass.classify(proto: d.connection.proto, port: d.connection.remote.port)
|
||||||
|
var hosts = endpoints[org.key] ?? [:]
|
||||||
|
var classes = hosts[host] ?? [:]
|
||||||
|
let prior = classes[cls] ?? (0, 0)
|
||||||
|
classes[cls] = (prior.0 + d.bytesInDelta, prior.1 + d.bytesOutDelta)
|
||||||
|
hosts[host] = classes
|
||||||
|
endpoints[org.key] = hosts
|
||||||
|
|
||||||
|
orgEndpoints[org.key, default: []].insert(host)
|
||||||
|
if let p = d.connection.remote.port, p > 0 { portForHost[host] = UInt16(p) }
|
||||||
|
probeIfNeeded(host)
|
||||||
|
}
|
||||||
|
let rendered = districts.map { key, d -> DistrictTraffic in
|
||||||
|
var d = d
|
||||||
|
d.endpoints = (endpoints[key] ?? [:]).map { host, classMap in
|
||||||
|
EndpointTraffic(host: host, flows: classMap.map {
|
||||||
|
ClassFlow(cls: $0.key, inBytes: $0.value.0, outBytes: $0.value.1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Distance = closest measured edge of this org (the nearest server you're served from).
|
||||||
|
d.rttMs = (orgEndpoints[key] ?? []).compactMap { rttForHost[$0] }.min()
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
scene.apply(rendered)
|
||||||
|
|
||||||
|
// Retain per-org detail for the inspector panel.
|
||||||
|
lastInterval = deltas.first?.interval ?? 2
|
||||||
|
lastEndpointBytes = endpoints
|
||||||
|
for d in rendered {
|
||||||
|
orgTitles[d.key] = d.title
|
||||||
|
orgProcesses[d.key, default: []].insert(d.subtitle)
|
||||||
|
}
|
||||||
|
if let key = selectedKey { selection = buildInspection(key) }
|
||||||
|
|
||||||
|
// HUD totals reflect *all* external traffic, resolved or not, so the
|
||||||
|
// throughput numbers stay honest while districts are still resolving.
|
||||||
|
downKBs = deltas.reduce(0) { $0 + $1.inBytesPerSec } / 1024
|
||||||
|
upKBs = deltas.reduce(0) { $0 + $1.outBytesPerSec } / 1024
|
||||||
|
districtCount = scene.buildingCount
|
||||||
|
live = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Inspector
|
||||||
|
|
||||||
|
func select(_ key: String?) {
|
||||||
|
selectedKey = key
|
||||||
|
scene.highlight(key: key)
|
||||||
|
selection = key.flatMap(buildInspection)
|
||||||
|
if let s = selection, ProcessInfo.processInfo.environment["NC_DEBUG"] != nil {
|
||||||
|
FileHandle.standardError.write(Data("[inspect] \(s.title): \(s.endpointCount) pts ▼\(Int(s.downKBs)) ▲\(Int(s.upKBs)) KB/s top=\(s.endpoints.first?.host ?? "-")\n".utf8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deselect() { select(nil) }
|
||||||
|
|
||||||
|
private func buildInspection(_ key: String) -> OrgInspection {
|
||||||
|
let hosts = orgEndpoints[key] ?? []
|
||||||
|
var infos: [EndpointInfo] = []
|
||||||
|
var totalDown = 0.0, totalUp = 0.0
|
||||||
|
|
||||||
|
for host in hosts {
|
||||||
|
let classMap = lastEndpointBytes[key]?[host] ?? [:]
|
||||||
|
var down = 0.0, up = 0.0
|
||||||
|
var top: (cls: TrafficClass, bytes: UInt64)?
|
||||||
|
for (cls, bytes) in classMap {
|
||||||
|
down += Double(bytes.0) / lastInterval / 1024
|
||||||
|
up += Double(bytes.1) / lastInterval / 1024
|
||||||
|
let total = bytes.0 + bytes.1
|
||||||
|
if top == nil || total > top!.bytes { top = (cls, total) }
|
||||||
|
}
|
||||||
|
totalDown += down
|
||||||
|
totalUp += up
|
||||||
|
infos.append(EndpointInfo(host: host, rdns: orgForHost[host]?.resolvedHost,
|
||||||
|
downKBs: down, upKBs: up, topClass: top?.cls ?? .other,
|
||||||
|
rttMs: rttForHost[host]))
|
||||||
|
}
|
||||||
|
infos.sort { ($0.downKBs + $0.upKBs) > ($1.downKBs + $1.upKBs) }
|
||||||
|
|
||||||
|
return OrgInspection(
|
||||||
|
key: key, title: orgTitles[key] ?? key,
|
||||||
|
downKBs: totalDown, upKBs: totalUp,
|
||||||
|
endpointCount: hosts.count, endpoints: infos,
|
||||||
|
processes: (orgProcesses[key] ?? []).sorted()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Measure RTT to a newly-seen endpoint once (cached), then store it so the
|
||||||
|
/// next tick can glide its district to the right distance.
|
||||||
|
private func probeIfNeeded(_ host: String) {
|
||||||
|
guard rttForHost[host] == nil, !rttPending.contains(host),
|
||||||
|
let port = portForHost[host] else { return }
|
||||||
|
rttPending.insert(host)
|
||||||
|
Task { [prober] in
|
||||||
|
// Unreachable (firewalled) endpoints read as "far" rather than missing.
|
||||||
|
let rtt = await prober.measure(host: host, port: port) ?? LatencyLayout.rttCeiling
|
||||||
|
rttForHost[host] = rtt
|
||||||
|
rttPending.remove(host)
|
||||||
|
if ProcessInfo.processInfo.environment["NC_DEBUG"] != nil {
|
||||||
|
FileHandle.standardError.write(Data("[rtt] \(host):\(port) → \(Int(rtt))ms\n".utf8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a host's org off the main thread, then store it back on the main
|
||||||
|
/// actor. Reverse DNS can block for seconds, so it runs detached.
|
||||||
|
private func resolve(_ host: String) {
|
||||||
|
guard orgForHost[host] == nil, pending.insert(host).inserted else { return }
|
||||||
|
Task {
|
||||||
|
let org = await Task.detached(priority: .utility) {
|
||||||
|
let ptr = ReverseDNS.resolve(host)
|
||||||
|
return OrgClassifier.classify(ip: host, ptr: ptr)
|
||||||
|
}.value
|
||||||
|
orgForHost[host] = org
|
||||||
|
pending.remove(host)
|
||||||
|
if ProcessInfo.processInfo.environment["NC_DEBUG"] != nil {
|
||||||
|
FileHandle.standardError.write(Data("[org] \(host) → \(org.name) (\(org.resolvedHost ?? "no-ptr"))\n".utf8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import SpriteKit
|
||||||
|
import NetworkCityCore
|
||||||
|
|
||||||
|
/// How a car of light looks for a given traffic class + magnitude.
|
||||||
|
struct CarStyle {
|
||||||
|
let color: SKColor
|
||||||
|
let scale: CGFloat
|
||||||
|
let elongation: CGFloat // 1 = round dot; >1 = streak along motion
|
||||||
|
let speed: CGFloat // points/sec
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Colour per traffic class — direction is read from motion, so hue is free to
|
||||||
|
/// mean protocol.
|
||||||
|
enum TrafficPalette {
|
||||||
|
static func color(_ c: TrafficClass) -> SKColor {
|
||||||
|
switch c {
|
||||||
|
case .dns: return SKColor(red: 1.00, green: 0.82, blue: 0.25, alpha: 1) // gold
|
||||||
|
case .https: return SKColor(red: 0.22, green: 0.88, blue: 1.00, alpha: 1) // cyan
|
||||||
|
case .http: return SKColor(red: 1.00, green: 0.48, blue: 0.24, alpha: 1) // orange
|
||||||
|
case .quic: return SKColor(red: 0.71, green: 0.48, blue: 1.00, alpha: 1) // violet
|
||||||
|
case .other: return SKColor(red: 0.55, green: 0.60, blue: 0.72, alpha: 1) // slate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Translate a class + this-tick byte volume into a car's personality. Heavy
|
||||||
|
/// flows become "freight": bigger, slower, stretched into a glowing comet.
|
||||||
|
func carStyle(_ cls: TrafficClass, bytes: UInt64) -> CarStyle {
|
||||||
|
let heavy = bytes > 196_608 // ~96 KB/s over a 2s tick
|
||||||
|
var scale: CGFloat
|
||||||
|
var elongation: CGFloat = 1
|
||||||
|
var speed: CGFloat = 230
|
||||||
|
|
||||||
|
switch cls {
|
||||||
|
case .dns: scale = 0.055; speed = 360 // tiny, fast sparks
|
||||||
|
case .https: scale = 0.085
|
||||||
|
case .http: scale = 0.085
|
||||||
|
case .quic: scale = 0.085; elongation = 2.4; speed = 285 // streaks
|
||||||
|
case .other: scale = 0.065
|
||||||
|
}
|
||||||
|
|
||||||
|
if heavy {
|
||||||
|
scale *= 1.7
|
||||||
|
elongation = max(elongation, 3.0) // freight comet
|
||||||
|
speed *= 0.7 // slow and heavy
|
||||||
|
}
|
||||||
|
return CarStyle(color: TrafficPalette.color(cls), scale: scale, elongation: elongation, speed: speed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Neon-night palette. Bright, saturated colors on near-black so additive
|
||||||
|
/// blending reads as glow.
|
||||||
|
enum Palette {
|
||||||
|
static let background = SKColor(red: 0.039, green: 0.055, blue: 0.102, alpha: 1) // #0a0e1a
|
||||||
|
static let grid = SKColor(red: 0.13, green: 0.20, blue: 0.38, alpha: 1)
|
||||||
|
static let hub = SKColor(red: 1.00, green: 0.85, blue: 0.62, alpha: 1) // warm downtown
|
||||||
|
static let building = SKColor(red: 0.50, green: 0.82, blue: 0.78, alpha: 1) // teal
|
||||||
|
static let road = SKColor(red: 0.17, green: 0.26, blue: 0.46, alpha: 1)
|
||||||
|
static let download = SKColor(red: 0.22, green: 0.88, blue: 1.00, alpha: 1) // cyan, inbound
|
||||||
|
static let upload = SKColor(red: 1.00, green: 0.62, blue: 0.30, alpha: 1) // amber, outbound
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CGFloat {
|
||||||
|
func clamped(_ lo: CGFloat, _ hi: CGFloat) -> CGFloat { Swift.min(Swift.max(self, lo), hi) }
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Textures {
|
||||||
|
/// A white radial-gradient disc (opaque center → transparent edge). Tint it
|
||||||
|
/// per-node with `colorBlendFactor` + `.add` blend mode to get a glow.
|
||||||
|
// Immutable after creation and only read; safe to share across actors.
|
||||||
|
nonisolated(unsafe) static let softCircle: SKTexture = makeSoftCircle(diameter: 128)
|
||||||
|
|
||||||
|
private static func makeSoftCircle(diameter: Int) -> SKTexture {
|
||||||
|
let space = CGColorSpaceCreateDeviceRGB()
|
||||||
|
let ctx = CGContext(
|
||||||
|
data: nil, width: diameter, height: diameter,
|
||||||
|
bitsPerComponent: 8, bytesPerRow: 0, space: space,
|
||||||
|
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||||
|
)!
|
||||||
|
let colors = [
|
||||||
|
SKColor.white.cgColor,
|
||||||
|
SKColor.white.withAlphaComponent(0).cgColor,
|
||||||
|
] as CFArray
|
||||||
|
let gradient = CGGradient(colorsSpace: space, colors: colors, locations: [0, 1])!
|
||||||
|
let center = CGPoint(x: diameter / 2, y: diameter / 2)
|
||||||
|
ctx.drawRadialGradient(
|
||||||
|
gradient, startCenter: center, startRadius: 0,
|
||||||
|
endCenter: center, endRadius: CGFloat(diameter) / 2, options: []
|
||||||
|
)
|
||||||
|
return SKTexture(cgImage: ctx.makeImage()!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deterministic FNV-1a hash so a given host always lands in the same place,
|
||||||
|
/// independent of Swift's per-process `Hasher` seed.
|
||||||
|
func stableHash(_ s: String) -> UInt64 {
|
||||||
|
var h: UInt64 = 1469598103934665603
|
||||||
|
for byte in s.utf8 { h = (h ^ UInt64(byte)) &* 1099511628211 }
|
||||||
|
return h
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The seam between *where the bytes come from* and *the city that renders them*.
|
||||||
|
///
|
||||||
|
/// Phase 1 backs this with `NettopSource`. Phase 2 can swap in a libpcap-based
|
||||||
|
/// source — anything that can emit `ConnectionSnapshot`s — and the renderer is
|
||||||
|
/// none the wiser.
|
||||||
|
public protocol ConnectionSource: Sendable {
|
||||||
|
/// Emits a snapshot roughly every `interval` seconds until the stream is
|
||||||
|
/// cancelled (e.g. the consumer breaks out of its `for await` loop).
|
||||||
|
func snapshots(every interval: TimeInterval) -> AsyncStream<ConnectionSnapshot>
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A grouping of destinations under one real-world owner. `key` is the stable
|
||||||
|
/// grouping id (a registrable domain, or a bare IP when unresolved); `name` is
|
||||||
|
/// what the district is labelled.
|
||||||
|
public struct Org: Sendable, Hashable {
|
||||||
|
public let key: String
|
||||||
|
public let name: String
|
||||||
|
public let resolvedHost: String?
|
||||||
|
|
||||||
|
public init(key: String, name: String, resolvedHost: String?) {
|
||||||
|
self.key = key
|
||||||
|
self.name = name
|
||||||
|
self.resolvedHost = resolvedHost
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps a reverse-DNS hostname to an `Org`. Pure and deterministic so it can be
|
||||||
|
/// unit-tested without touching the network.
|
||||||
|
public enum OrgClassifier {
|
||||||
|
|
||||||
|
private struct Rule { let key: String; let name: String; let domains: Set<String> }
|
||||||
|
|
||||||
|
/// Well-known owners whose traffic spreads across many registrable domains.
|
||||||
|
/// Anything matched here collapses to a single district.
|
||||||
|
private static let rules: [Rule] = [
|
||||||
|
Rule(key: "apple", name: "Apple",
|
||||||
|
domains: ["apple.com", "icloud.com", "aaplimg.com", "mzstatic.com", "cdn-apple.com", "apple-dns.net"]),
|
||||||
|
Rule(key: "google", name: "Google",
|
||||||
|
domains: ["google.com", "googleusercontent.com", "gstatic.com", "googleapis.com",
|
||||||
|
"1e100.net", "ggpht.com", "youtube.com", "ytimg.com", "google-analytics.com"]),
|
||||||
|
Rule(key: "amazon", name: "Amazon / AWS",
|
||||||
|
domains: ["amazonaws.com", "amazon.com", "cloudfront.net", "aws.dev", "awsglobalaccelerator.com"]),
|
||||||
|
Rule(key: "microsoft", name: "Microsoft",
|
||||||
|
domains: ["microsoft.com", "windows.com", "windowsupdate.com", "azure.com",
|
||||||
|
"azureedge.net", "office.com", "live.com", "msftncsi.com"]),
|
||||||
|
Rule(key: "meta", name: "Meta",
|
||||||
|
domains: ["facebook.com", "fbcdn.net", "instagram.com", "whatsapp.net", "fb.com"]),
|
||||||
|
Rule(key: "cloudflare", name: "Cloudflare",
|
||||||
|
domains: ["cloudflare.com", "cloudflare-dns.com", "cf-dns.com"]),
|
||||||
|
Rule(key: "akamai", name: "Akamai",
|
||||||
|
domains: ["akamai.net", "akamaiedge.net", "akamaitechnologies.com", "akadns.net"]),
|
||||||
|
Rule(key: "fastly", name: "Fastly",
|
||||||
|
domains: ["fastly.net", "fastlylb.net"]),
|
||||||
|
Rule(key: "github", name: "GitHub",
|
||||||
|
domains: ["github.com", "githubusercontent.com", "githubassets.com"]),
|
||||||
|
Rule(key: "spotify", name: "Spotify",
|
||||||
|
domains: ["spotify.com", "scdn.co", "spotifycdn.com"]),
|
||||||
|
Rule(key: "netflix", name: "Netflix",
|
||||||
|
domains: ["netflix.com", "nflxvideo.net", "nflxso.net", "nflximg.net"]),
|
||||||
|
Rule(key: "telegram", name: "Telegram",
|
||||||
|
domains: ["telegram.org", "t.me", "telegram.me"]),
|
||||||
|
Rule(key: "anthropic", name: "Anthropic",
|
||||||
|
domains: ["anthropic.com", "claude.ai"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Registrable suffixes that span two labels, so the registrable domain is
|
||||||
|
/// the last *three* labels (e.g. `bbc.co.uk`, not `co.uk`).
|
||||||
|
private static let multiPartSuffixes: Set<String> = [
|
||||||
|
"co.uk", "org.uk", "gov.uk", "ac.uk", "co.jp", "co.nz", "co.in",
|
||||||
|
"com.au", "net.au", "org.au", "com.br", "com.cn", "com.mx", "co.kr", "co.za",
|
||||||
|
]
|
||||||
|
|
||||||
|
public static func classify(ip: String, ptr: String?) -> Org {
|
||||||
|
// 1. A PTR with a real registrable domain is the most accurate signal.
|
||||||
|
if let ptr, let reg = registrableDomain(ptr) {
|
||||||
|
if let rule = rules.first(where: { $0.domains.contains(reg) }) {
|
||||||
|
return Org(key: rule.key, name: rule.name, resolvedHost: ptr)
|
||||||
|
}
|
||||||
|
return Org(key: reg, name: prettify(reg), resolvedHost: ptr)
|
||||||
|
}
|
||||||
|
// 2. No usable PTR — fall back to the curated CIDR table (Apple's 17/8,
|
||||||
|
// Telegram, private LAN, …).
|
||||||
|
if let match = IPRanges.match(ip) {
|
||||||
|
return Org(key: match.key, name: match.name, resolvedHost: ptr)
|
||||||
|
}
|
||||||
|
// 3. Unknown: the IP is its own (unlabelled) district.
|
||||||
|
return Org(key: ip, name: ip, resolvedHost: ptr)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// eTLD+1 with a small built-in multi-part-suffix table. `a.b.example.co.uk`
|
||||||
|
/// -> `example.co.uk`; `cdn.gstatic.com` -> `gstatic.com`.
|
||||||
|
public static func registrableDomain(_ host: String) -> String? {
|
||||||
|
let trimmed = host.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
||||||
|
let labels = trimmed.split(separator: ".").map(String.init)
|
||||||
|
guard labels.count >= 2 else { return nil }
|
||||||
|
let lastTwo = labels.suffix(2).joined(separator: ".")
|
||||||
|
if labels.count >= 3 && multiPartSuffixes.contains(lastTwo) {
|
||||||
|
return labels.suffix(3).joined(separator: ".")
|
||||||
|
}
|
||||||
|
return lastTwo
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `example.co.uk` -> `Example`.
|
||||||
|
public static func prettify(_ registrable: String) -> String {
|
||||||
|
guard let sld = registrable.split(separator: ".").first else { return registrable }
|
||||||
|
return sld.prefix(1).uppercased() + sld.dropFirst()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Decides which districts auto-expand into hub-and-spoke each tick.
|
||||||
|
///
|
||||||
|
/// Rules: a district expands only if it has at least two endpoints (a single-IP
|
||||||
|
/// org has nothing to fan out) and ranks among the busiest `topK` above
|
||||||
|
/// `floorBytes`. Once chosen it stays expanded for `ttlTicks` ticks, so a burst
|
||||||
|
/// leaves a smooth tail instead of flickering collapsed the moment it idles.
|
||||||
|
///
|
||||||
|
/// Pure and deterministic given its input — no SpriteKit, fully unit-testable.
|
||||||
|
public final class ExpansionPolicy {
|
||||||
|
public struct District: Sendable {
|
||||||
|
public let key: String
|
||||||
|
public let bytes: UInt64
|
||||||
|
public let endpointCount: Int
|
||||||
|
public init(key: String, bytes: UInt64, endpointCount: Int) {
|
||||||
|
self.key = key
|
||||||
|
self.bytes = bytes
|
||||||
|
self.endpointCount = endpointCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let topK: Int
|
||||||
|
private let ttlTicks: Int
|
||||||
|
private let floorBytes: UInt64
|
||||||
|
private var ttl: [String: Int] = [:]
|
||||||
|
|
||||||
|
public init(topK: Int = 4, ttlTicks: Int = 4, floorBytes: UInt64 = 8 * 1024) {
|
||||||
|
self.topK = topK
|
||||||
|
self.ttlTicks = ttlTicks
|
||||||
|
self.floorBytes = floorBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advance one tick over the full set of currently-known districts (include
|
||||||
|
/// idle ones so their tail can decay). Returns the set of keys to render
|
||||||
|
/// expanded.
|
||||||
|
public func update(_ districts: [District]) -> Set<String> {
|
||||||
|
// Decay every existing timer.
|
||||||
|
for key in ttl.keys { ttl[key] = max(0, (ttl[key] ?? 0) - 1) }
|
||||||
|
|
||||||
|
// Refresh the busiest multi-endpoint districts.
|
||||||
|
var winners = 0
|
||||||
|
for d in districts.sorted(by: { $0.bytes > $1.bytes }) {
|
||||||
|
guard winners < topK else { break }
|
||||||
|
if d.bytes >= floorBytes && d.endpointCount >= 2 {
|
||||||
|
ttl[d.key] = ttlTicks
|
||||||
|
winners += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expanded = live timer AND still has something to fan out.
|
||||||
|
let endpointCounts = Dictionary(districts.map { ($0.key, $0.endpointCount) },
|
||||||
|
uniquingKeysWith: { a, _ in a })
|
||||||
|
var expanded = Set<String>()
|
||||||
|
for (key, remaining) in ttl where remaining > 0 {
|
||||||
|
if (endpointCounts[key] ?? 0) >= 2 { expanded.insert(key) }
|
||||||
|
}
|
||||||
|
return expanded
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A curated CIDR → owner table, used as a fallback when a host has no PTR
|
||||||
|
/// record. Many large operators (Apple's 17.0.0.0/8 famously, Telegram, the
|
||||||
|
/// cloud edges) publish no reverse DNS, so this recovers the obvious ones.
|
||||||
|
/// IPv4 only — the no-PTR giants we care about are reachable on v4.
|
||||||
|
public enum IPRanges {
|
||||||
|
|
||||||
|
private struct Block { let net: UInt32; let mask: UInt32; let key: String; let name: String }
|
||||||
|
|
||||||
|
private static let blocks: [Block] = [
|
||||||
|
("17.0.0.0/8", "apple", "Apple"),
|
||||||
|
("149.154.160.0/20", "telegram", "Telegram"),
|
||||||
|
("91.108.0.0/16", "telegram", "Telegram"),
|
||||||
|
("13.64.0.0/11", "microsoft", "Microsoft"),
|
||||||
|
("157.240.0.0/16", "meta", "Meta"),
|
||||||
|
("31.13.24.0/21", "meta", "Meta"),
|
||||||
|
("129.134.0.0/16", "meta", "Meta"),
|
||||||
|
("1.1.1.0/24", "cloudflare", "Cloudflare"),
|
||||||
|
("104.16.0.0/13", "cloudflare", "Cloudflare"),
|
||||||
|
// Private / link-local space collapses into one neighbourhood.
|
||||||
|
("10.0.0.0/8", "lan", "Local Network"),
|
||||||
|
("172.16.0.0/12", "lan", "Local Network"),
|
||||||
|
("192.168.0.0/16", "lan", "Local Network"),
|
||||||
|
("169.254.0.0/16", "lan", "Local Network"),
|
||||||
|
].compactMap { parse($0.0, key: $0.1, name: $0.2) }
|
||||||
|
|
||||||
|
public static func match(_ ip: String) -> (key: String, name: String)? {
|
||||||
|
guard let value = ipv4ToUInt32(ip) else { return nil }
|
||||||
|
for b in blocks where (value & b.mask) == b.net {
|
||||||
|
return (b.key, b.name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Parsing
|
||||||
|
|
||||||
|
private static func parse(_ cidr: String, key: String, name: String) -> Block? {
|
||||||
|
let parts = cidr.split(separator: "/")
|
||||||
|
guard parts.count == 2, let bits = UInt32(parts[1]), bits <= 32,
|
||||||
|
let net = ipv4ToUInt32(String(parts[0])) else { return nil }
|
||||||
|
let mask: UInt32 = bits == 0 ? 0 : ~UInt32(0) << (32 - bits)
|
||||||
|
return Block(net: net & mask, mask: mask, key: key, name: name)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func ipv4ToUInt32(_ ip: String) -> UInt32? {
|
||||||
|
let octets = ip.split(separator: ".")
|
||||||
|
guard octets.count == 4 else { return nil }
|
||||||
|
var result: UInt32 = 0
|
||||||
|
for octet in octets {
|
||||||
|
guard let n = UInt32(octet), n <= 255 else { return nil }
|
||||||
|
result = (result << 8) | n
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import Foundation
|
||||||
|
import CoreGraphics
|
||||||
|
|
||||||
|
/// Maps a measured round-trip time (ms) to a radial distance from downtown.
|
||||||
|
/// A log curve so the busy near range (1–50ms LAN/CDN) spreads out instead of
|
||||||
|
/// bunching at the center, while the long tail (transcontinental) saturates.
|
||||||
|
public enum LatencyLayout {
|
||||||
|
public static let minRadius: CGFloat = 150
|
||||||
|
public static let maxRadius: CGFloat = 560
|
||||||
|
public static let rttCeiling: Double = 250 // ms mapped to the outer edge
|
||||||
|
|
||||||
|
public static func radius(forRTT rtt: Double) -> CGFloat {
|
||||||
|
let clamped = max(0, min(rtt, rttCeiling))
|
||||||
|
let norm = log10(1 + clamped) / log10(1 + rttCeiling)
|
||||||
|
return minRadius + CGFloat(norm) * (maxRadius - minRadius)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
|
||||||
|
/// Measures round-trip time to an endpoint with an unprivileged TCP connect
|
||||||
|
/// (time from start to `.ready` ≈ one handshake RTT). Results are cached.
|
||||||
|
///
|
||||||
|
/// We probe a port we've already seen traffic on, so we're not knocking on
|
||||||
|
/// closed doors — and the connection carries no data, just the handshake.
|
||||||
|
public actor LatencyProber {
|
||||||
|
private var cache: [String: Double] = [:]
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func cached(_ host: String) -> Double? { cache[host] }
|
||||||
|
|
||||||
|
/// Measure (or refresh) RTT in milliseconds. Returns nil if unreachable.
|
||||||
|
public func measure(host: String, port: UInt16) async -> Double? {
|
||||||
|
let rtt = await Self.tcpHandshakeRTT(host: host, port: port)
|
||||||
|
if let rtt { cache[host] = rtt }
|
||||||
|
return rtt
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func tcpHandshakeRTT(host: String, port: UInt16) async -> Double? {
|
||||||
|
guard let nwPort = NWEndpoint.Port(rawValue: port) else { return nil }
|
||||||
|
|
||||||
|
return await withCheckedContinuation { (cont: CheckedContinuation<Double?, Never>) in
|
||||||
|
let conn = NWConnection(host: NWEndpoint.Host(host), port: nwPort, using: .tcp)
|
||||||
|
let queue = DispatchQueue(label: "latency.probe")
|
||||||
|
let start = DispatchTime.now()
|
||||||
|
let state = ProbeState(conn: conn, cont: cont)
|
||||||
|
|
||||||
|
conn.stateUpdateHandler = { st in
|
||||||
|
switch st {
|
||||||
|
case .ready:
|
||||||
|
let ns = DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds
|
||||||
|
state.finish(Double(ns) / 1_000_000)
|
||||||
|
case .failed, .cancelled:
|
||||||
|
state.finish(nil)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
conn.start(queue: queue)
|
||||||
|
queue.asyncAfter(deadline: .now() + 2) { state.finish(nil) } // timeout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resumes the continuation exactly once, whichever callback fires first.
|
||||||
|
private final class ProbeState: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var done = false
|
||||||
|
private let conn: NWConnection
|
||||||
|
private let cont: CheckedContinuation<Double?, Never>
|
||||||
|
|
||||||
|
init(conn: NWConnection, cont: CheckedContinuation<Double?, Never>) {
|
||||||
|
self.conn = conn
|
||||||
|
self.cont = cont
|
||||||
|
}
|
||||||
|
|
||||||
|
func finish(_ value: Double?) {
|
||||||
|
lock.lock()
|
||||||
|
if done { lock.unlock(); return }
|
||||||
|
done = true
|
||||||
|
lock.unlock()
|
||||||
|
conn.cancel()
|
||||||
|
cont.resume(returning: value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import Foundation
|
||||||
|
import CoreGraphics
|
||||||
|
|
||||||
|
/// Places district labels so their boxes don't overlap. Each district starts at
|
||||||
|
/// a hash-derived seed; if its (padded) label box collides with an already-placed
|
||||||
|
/// one, we spiral outward — rotating and pushing away from downtown — until we
|
||||||
|
/// find clear space. Existing placements never move, so the map stays stable as
|
||||||
|
/// new districts appear.
|
||||||
|
///
|
||||||
|
/// Pure and deterministic given its inputs; no SpriteKit, fully unit-testable.
|
||||||
|
public enum LayoutSolver {
|
||||||
|
|
||||||
|
/// - Parameters:
|
||||||
|
/// - baseAngle/baseRadius: the hash-derived seed in polar coords.
|
||||||
|
/// - localRect: the label box *relative to the node center* (labels sit
|
||||||
|
/// below the dot, so this is usually offset downward).
|
||||||
|
/// - existing: already-placed label boxes in world space.
|
||||||
|
/// - pad: minimum gap to keep between boxes.
|
||||||
|
/// - Returns: a world-space center for the new district.
|
||||||
|
public static func placeNonOverlapping(
|
||||||
|
baseAngle: Double,
|
||||||
|
baseRadius: Double,
|
||||||
|
localRect: CGRect,
|
||||||
|
existing: [CGRect],
|
||||||
|
pad: CGFloat = 16,
|
||||||
|
maxAttempts: Int = 400
|
||||||
|
) -> CGPoint {
|
||||||
|
for attempt in 0..<maxAttempts {
|
||||||
|
let angle = baseAngle + Double(attempt) * 0.45 // rotate as we go
|
||||||
|
let radius = baseRadius + Double(attempt) * 7 // and push outward
|
||||||
|
let center = CGPoint(x: cos(angle) * radius, y: sin(angle) * radius)
|
||||||
|
let box = localRect.offsetBy(dx: center.x, dy: center.y).insetBy(dx: -pad, dy: -pad)
|
||||||
|
if !existing.contains(where: { $0.intersects(box) }) {
|
||||||
|
return center
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Give up gracefully at the seed (extremely crowded map).
|
||||||
|
return CGPoint(x: cos(baseAngle) * baseRadius, y: sin(baseAngle) * baseRadius)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Transport flavour as reported by `nettop` (the trailing 4/6 is the IP version).
|
||||||
|
public enum NetProtocol: String, Sendable, Hashable {
|
||||||
|
case tcp4, tcp6, udp4, udp6, quic4, quic6, other
|
||||||
|
|
||||||
|
public init(_ raw: String) {
|
||||||
|
self = NetProtocol(rawValue: raw) ?? .other
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One end of a connection. `port` is nil for a wildcard (`*`) — i.e. a listener.
|
||||||
|
public struct Endpoint: Sendable, Hashable {
|
||||||
|
public let host: String
|
||||||
|
public let port: Int?
|
||||||
|
|
||||||
|
public init(host: String, port: Int?) {
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
}
|
||||||
|
|
||||||
|
public var isWildcard: Bool { host == "*" || host.isEmpty }
|
||||||
|
|
||||||
|
public var display: String {
|
||||||
|
guard let port else { return host }
|
||||||
|
return "\(host):\(port)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single connection sampled from `nettop`. Byte counts are cumulative
|
||||||
|
/// (lifetime of the connection), so traffic *rates* come from diffing snapshots.
|
||||||
|
public struct Connection: Sendable, Hashable {
|
||||||
|
public let proto: NetProtocol
|
||||||
|
public let local: Endpoint
|
||||||
|
public let remote: Endpoint
|
||||||
|
public let bytesIn: UInt64
|
||||||
|
public let bytesOut: UInt64
|
||||||
|
public let processName: String
|
||||||
|
public let pid: Int32
|
||||||
|
|
||||||
|
public init(
|
||||||
|
proto: NetProtocol,
|
||||||
|
local: Endpoint,
|
||||||
|
remote: Endpoint,
|
||||||
|
bytesIn: UInt64,
|
||||||
|
bytesOut: UInt64,
|
||||||
|
processName: String,
|
||||||
|
pid: Int32
|
||||||
|
) {
|
||||||
|
self.proto = proto
|
||||||
|
self.local = local
|
||||||
|
self.remote = remote
|
||||||
|
self.bytesIn = bytesIn
|
||||||
|
self.bytesOut = bytesOut
|
||||||
|
self.processName = processName
|
||||||
|
self.pid = pid
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable identity for diffing across snapshots (ignores byte counts).
|
||||||
|
public var key: String {
|
||||||
|
"\(proto.rawValue) \(local.display)<->\(remote.display)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when the remote is a real off-box destination — the only kind that
|
||||||
|
/// becomes a "building" in the city. Filters listeners, loopback, link-local.
|
||||||
|
public var isExternal: Bool {
|
||||||
|
let h = remote.host
|
||||||
|
if remote.isWildcard { return false }
|
||||||
|
if h == "::1" || h.hasPrefix("127.") { return false } // loopback
|
||||||
|
if h.hasPrefix("fe80") || h == "::" { return false } // link-local / unspecified
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when the remote is on the local network (a "neighbourhood" district).
|
||||||
|
public var isPrivateLAN: Bool {
|
||||||
|
let h = remote.host
|
||||||
|
return h.hasPrefix("10.")
|
||||||
|
|| h.hasPrefix("192.168.")
|
||||||
|
|| h.hasPrefix("169.254.")
|
||||||
|
|| (h.hasPrefix("172.") && (16...31).contains(Int(h.split(separator: ".").dropFirst().first ?? "") ?? -1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All connections observed at one instant.
|
||||||
|
public struct ConnectionSnapshot: Sendable {
|
||||||
|
public let timestamp: Date
|
||||||
|
public let connections: [Connection]
|
||||||
|
|
||||||
|
public init(timestamp: Date, connections: [Connection]) {
|
||||||
|
self.timestamp = timestamp
|
||||||
|
self.connections = connections
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import Foundation
|
||||||
|
import Darwin
|
||||||
|
|
||||||
|
/// Resolves a pid to its real executable name via `proc_pidpath`, because
|
||||||
|
/// nettop's name column is unreliable (it reported `claude` as `2.1.177`).
|
||||||
|
/// Results are cached; drive from a single thread/actor.
|
||||||
|
public final class ProcessNamer {
|
||||||
|
private var cache: [Int32: String] = [:]
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func name(for pid: Int32) -> String? {
|
||||||
|
guard pid > 0 else { return nil }
|
||||||
|
if let hit = cache[pid] { return hit }
|
||||||
|
|
||||||
|
var buffer = [CChar](repeating: 0, count: 4096) // PROC_PIDPATHINFO_MAXSIZE
|
||||||
|
let length = proc_pidpath(pid, &buffer, UInt32(buffer.count))
|
||||||
|
guard length > 0 else { return nil }
|
||||||
|
|
||||||
|
let path = String(cString: buffer)
|
||||||
|
// `/Applications/Google Chrome.app/.../Google Chrome Helper` -> last component
|
||||||
|
let name = (path as NSString).lastPathComponent
|
||||||
|
cache[pid] = name
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import Foundation
|
||||||
|
import Darwin
|
||||||
|
|
||||||
|
/// Best-effort reverse DNS (IP -> PTR hostname). Blocking — call it off the main
|
||||||
|
/// thread. Returns nil when there's no PTR record (we don't want a numeric echo).
|
||||||
|
public enum ReverseDNS {
|
||||||
|
|
||||||
|
public static func resolve(_ ip: String) -> String? {
|
||||||
|
var storage = sockaddr_storage()
|
||||||
|
var length: socklen_t = 0
|
||||||
|
|
||||||
|
if ip.contains(":") {
|
||||||
|
var sa = sockaddr_in6()
|
||||||
|
sa.sin6_family = sa_family_t(AF_INET6)
|
||||||
|
sa.sin6_len = UInt8(MemoryLayout<sockaddr_in6>.size)
|
||||||
|
guard inet_pton(AF_INET6, ip, &sa.sin6_addr) == 1 else { return nil }
|
||||||
|
withUnsafeBytes(of: &sa) { src in
|
||||||
|
withUnsafeMutableBytes(of: &storage) { dst in
|
||||||
|
dst.copyMemory(from: UnsafeRawBufferPointer(rebasing: src[0..<MemoryLayout<sockaddr_in6>.size]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
length = socklen_t(MemoryLayout<sockaddr_in6>.size)
|
||||||
|
} else {
|
||||||
|
var sa = sockaddr_in()
|
||||||
|
sa.sin_family = sa_family_t(AF_INET)
|
||||||
|
sa.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
|
||||||
|
guard inet_pton(AF_INET, ip, &sa.sin_addr) == 1 else { return nil }
|
||||||
|
withUnsafeBytes(of: &sa) { src in
|
||||||
|
withUnsafeMutableBytes(of: &storage) { dst in
|
||||||
|
dst.copyMemory(from: UnsafeRawBufferPointer(rebasing: src[0..<MemoryLayout<sockaddr_in>.size]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
length = socklen_t(MemoryLayout<sockaddr_in>.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
var host = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||||
|
let result = withUnsafePointer(to: &storage) { ptr in
|
||||||
|
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||||
|
getnameinfo(sa, length, &host, socklen_t(host.count), nil, 0, NI_NAMEREQD)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard result == 0 else { return nil }
|
||||||
|
let name = String(cString: host)
|
||||||
|
return name.isEmpty ? nil : name
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A coarse traffic category derived from protocol + port, used to colour the
|
||||||
|
/// cars of light. Direction (in/out) is conveyed by motion, so colour is free
|
||||||
|
/// to mean "what kind of traffic is this".
|
||||||
|
public enum TrafficClass: String, Sendable, CaseIterable {
|
||||||
|
case dns
|
||||||
|
case https
|
||||||
|
case http
|
||||||
|
case quic
|
||||||
|
case other
|
||||||
|
|
||||||
|
/// Human label for the legend.
|
||||||
|
public var label: String {
|
||||||
|
switch self {
|
||||||
|
case .dns: return "DNS"
|
||||||
|
case .https: return "HTTPS"
|
||||||
|
case .http: return "HTTP"
|
||||||
|
case .quic: return "QUIC"
|
||||||
|
case .other: return "Other"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func classify(proto: NetProtocol, port: Int?) -> TrafficClass {
|
||||||
|
// nettop labels QUIC explicitly; trust that first.
|
||||||
|
if proto == .quic4 || proto == .quic6 { return .quic }
|
||||||
|
|
||||||
|
let isUDP = (proto == .udp4 || proto == .udp6)
|
||||||
|
switch port {
|
||||||
|
case 53, 5353:
|
||||||
|
return .dns
|
||||||
|
case 443:
|
||||||
|
return isUDP ? .quic : .https // UDP/443 is almost always QUIC
|
||||||
|
case 80, 8080:
|
||||||
|
return .http
|
||||||
|
default:
|
||||||
|
return .other
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// One connection's traffic over the interval between two snapshots.
|
||||||
|
public struct TrafficDelta: Sendable {
|
||||||
|
public let connection: Connection
|
||||||
|
public let bytesInDelta: UInt64
|
||||||
|
public let bytesOutDelta: UInt64
|
||||||
|
public let interval: TimeInterval
|
||||||
|
|
||||||
|
public var inBytesPerSec: Double { interval > 0 ? Double(bytesInDelta) / interval : 0 }
|
||||||
|
public var outBytesPerSec: Double { interval > 0 ? Double(bytesOutDelta) / interval : 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turns the stream of cumulative-byte snapshots into per-interval deltas by
|
||||||
|
/// remembering the previous reading for each connection key. Shared by the CLI
|
||||||
|
/// probe and the SpriteKit city so there's one source of truth for "traffic".
|
||||||
|
///
|
||||||
|
/// Not thread-safe by design — drive it from a single actor/thread.
|
||||||
|
public final class TrafficDiffer {
|
||||||
|
private var previous: [String: (UInt64, UInt64)] = [:]
|
||||||
|
private var lastTimestamp: Date?
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func ingest(_ snapshot: ConnectionSnapshot) -> [TrafficDelta] {
|
||||||
|
let interval = lastTimestamp.map { snapshot.timestamp.timeIntervalSince($0) } ?? 0
|
||||||
|
lastTimestamp = snapshot.timestamp
|
||||||
|
|
||||||
|
var next: [String: (UInt64, UInt64)] = [:]
|
||||||
|
var deltas: [TrafficDelta] = []
|
||||||
|
|
||||||
|
for c in snapshot.connections {
|
||||||
|
next[c.key] = (c.bytesIn, c.bytesOut)
|
||||||
|
guard interval > 0, let (pIn, pOut) = previous[c.key] else { continue }
|
||||||
|
// A drop in the counter means the tuple was reused by a new connection;
|
||||||
|
// treat as zero rather than underflow.
|
||||||
|
let dIn = c.bytesIn >= pIn ? c.bytesIn - pIn : 0
|
||||||
|
let dOut = c.bytesOut >= pOut ? c.bytesOut - pOut : 0
|
||||||
|
if dIn + dOut > 0 {
|
||||||
|
deltas.append(TrafficDelta(
|
||||||
|
connection: c, bytesInDelta: dIn, bytesOutDelta: dOut, interval: interval
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previous = next
|
||||||
|
return deltas
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import Foundation
|
||||||
|
import NetworkCityCore
|
||||||
|
|
||||||
|
// Phase-1 proof of concept. Polls nettop, diffs successive snapshots, and prints
|
||||||
|
// the busiest external flows as live KB/s — i.e. the raw material that will
|
||||||
|
// become cars driving to buildings.
|
||||||
|
|
||||||
|
setvbuf(stdout, nil, _IONBF, 0) // unbuffered: see output live even when piped
|
||||||
|
|
||||||
|
let interval: TimeInterval = 2.0
|
||||||
|
let source = NettopSource()
|
||||||
|
let differ = TrafficDiffer()
|
||||||
|
var warmedUp = false
|
||||||
|
|
||||||
|
func pad(_ s: String, _ width: Int) -> String {
|
||||||
|
s.count >= width ? String(s.prefix(width)) : s + String(repeating: " ", count: width - s.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
print("📡 NetworkCity probe — sampling every \(Int(interval))s. Ctrl-C to stop.\n")
|
||||||
|
|
||||||
|
for await snap in source.snapshots(every: interval) {
|
||||||
|
let deltas = differ.ingest(snap).filter { $0.connection.isExternal }
|
||||||
|
let external = snap.connections.filter(\.isExternal).count
|
||||||
|
|
||||||
|
if !warmedUp {
|
||||||
|
warmedUp = true
|
||||||
|
print("… warming up (need two samples to compute rates) …\n")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let active = deltas.sorted { ($0.inBytesPerSec + $0.outBytesPerSec) > ($1.inBytesPerSec + $1.outBytesPerSec) }
|
||||||
|
let ts = DateFormatter.localizedString(from: snap.timestamp, dateStyle: .none, timeStyle: .medium)
|
||||||
|
|
||||||
|
print("─── \(ts) \(active.count) active / \(external) external connections ───")
|
||||||
|
print("\(pad("PROCESS", 18)) \(pad("DESTINATION", 24)) ↓ KB/s ↑ KB/s")
|
||||||
|
if active.isEmpty {
|
||||||
|
print(" (quiet — no measurable traffic this interval)")
|
||||||
|
}
|
||||||
|
for d in active.prefix(15) {
|
||||||
|
let dest = d.connection.remote.display
|
||||||
|
print("\(pad(d.connection.processName, 18)) \(pad(dest, 24)) "
|
||||||
|
+ "\(String(format: "%9.1f", d.inBytesPerSec / 1024)) \(String(format: "%9.1f", d.outBytesPerSec / 1024))")
|
||||||
|
}
|
||||||
|
print("")
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import NetworkCityCore
|
||||||
|
|
||||||
|
final class ExpansionPolicyTests: XCTestCase {
|
||||||
|
typealias D = ExpansionPolicy.District
|
||||||
|
|
||||||
|
func testBusyMultiEndpointOrgExpands() {
|
||||||
|
let policy = ExpansionPolicy()
|
||||||
|
let expanded = policy.update([D(key: "onedrive", bytes: 500_000, endpointCount: 15)])
|
||||||
|
XCTAssertTrue(expanded.contains("onedrive"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSingleEndpointNeverExpandsEvenIfHuge() {
|
||||||
|
// The exact case we saw live: Google at 126 MB but only one active IP.
|
||||||
|
let policy = ExpansionPolicy()
|
||||||
|
let expanded = policy.update([D(key: "google", bytes: 126_000_000, endpointCount: 1)])
|
||||||
|
XCTAssertTrue(expanded.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testQuietOrgDoesNotExpand() {
|
||||||
|
let policy = ExpansionPolicy(floorBytes: 8 * 1024)
|
||||||
|
let expanded = policy.update([D(key: "apple", bytes: 100, endpointCount: 5)])
|
||||||
|
XCTAssertTrue(expanded.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOnlyTopKExpand() {
|
||||||
|
let policy = ExpansionPolicy(topK: 2)
|
||||||
|
let districts = (0..<5).map { D(key: "org\($0)", bytes: UInt64(1000 - $0) * 1024, endpointCount: 3) }
|
||||||
|
let expanded = policy.update(districts)
|
||||||
|
XCTAssertEqual(expanded, ["org0", "org1"]) // two busiest only
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHysteresisKeepsExpandedThenCollapses() {
|
||||||
|
let policy = ExpansionPolicy(ttlTicks: 3)
|
||||||
|
// Tick 1: busy -> expands.
|
||||||
|
XCTAssertTrue(policy.update([D(key: "x", bytes: 1_000_000, endpointCount: 4)]).contains("x"))
|
||||||
|
// Now idle, but still has endpoints: stays up for the TTL tail.
|
||||||
|
XCTAssertTrue(policy.update([D(key: "x", bytes: 0, endpointCount: 4)]).contains("x")) // ttl 2
|
||||||
|
XCTAssertTrue(policy.update([D(key: "x", bytes: 0, endpointCount: 4)]).contains("x")) // ttl 1
|
||||||
|
XCTAssertFalse(policy.update([D(key: "x", bytes: 0, endpointCount: 4)]).contains("x")) // ttl 0 -> collapsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func testExpiredTimerWithLostEndpointsStaysCollapsed() {
|
||||||
|
let policy = ExpansionPolicy(ttlTicks: 2)
|
||||||
|
_ = policy.update([D(key: "x", bytes: 1_000_000, endpointCount: 4)])
|
||||||
|
// Endpoints drop to 1 while timer still alive -> nothing to fan out.
|
||||||
|
XCTAssertFalse(policy.update([D(key: "x", bytes: 0, endpointCount: 1)]).contains("x"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import NetworkCityCore
|
||||||
|
|
||||||
|
final class IPRangesTests: XCTestCase {
|
||||||
|
|
||||||
|
func testAppleEightSlashEight() {
|
||||||
|
XCTAssertEqual(IPRanges.match("17.248.242.19")?.key, "apple")
|
||||||
|
XCTAssertEqual(IPRanges.match("17.42.251.69")?.name, "Apple")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTelegram() {
|
||||||
|
XCTAssertEqual(IPRanges.match("149.154.175.54")?.key, "telegram")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPrivateRangesAreLocalNetwork() {
|
||||||
|
XCTAssertEqual(IPRanges.match("192.168.0.1")?.name, "Local Network")
|
||||||
|
XCTAssertEqual(IPRanges.match("10.1.2.3")?.key, "lan")
|
||||||
|
XCTAssertEqual(IPRanges.match("172.20.0.5")?.key, "lan")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUnknownIPReturnsNil() {
|
||||||
|
XCTAssertNil(IPRanges.match("8.8.8.8"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInvalidIPReturnsNil() {
|
||||||
|
XCTAssertNil(IPRanges.match("not.an.ip"))
|
||||||
|
XCTAssertNil(IPRanges.match("999.1.1.1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testClassifyUsesRangeFallbackWhenNoPTR() {
|
||||||
|
// Apple publishes no PTR for 17/8, so the range table must catch it.
|
||||||
|
let org = OrgClassifier.classify(ip: "17.248.242.19", ptr: nil)
|
||||||
|
XCTAssertEqual(org.key, "apple")
|
||||||
|
XCTAssertEqual(org.name, "Apple")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPTRStillWinsOverRange() {
|
||||||
|
// A real registrable domain in the PTR should take precedence.
|
||||||
|
let org = OrgClassifier.classify(ip: "17.248.242.19", ptr: "host.example.com")
|
||||||
|
XCTAssertEqual(org.key, "example.com")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import XCTest
|
||||||
|
import CoreGraphics
|
||||||
|
@testable import NetworkCityCore
|
||||||
|
|
||||||
|
final class LatencyLayoutTests: XCTestCase {
|
||||||
|
|
||||||
|
func testZeroRTTSitsAtInnerRadius() {
|
||||||
|
XCTAssertEqual(LatencyLayout.radius(forRTT: 0), LatencyLayout.minRadius, accuracy: 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCeilingSitsAtOuterEdge() {
|
||||||
|
XCTAssertEqual(LatencyLayout.radius(forRTT: 250), LatencyLayout.maxRadius, accuracy: 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testClampsBeyondCeiling() {
|
||||||
|
XCTAssertEqual(LatencyLayout.radius(forRTT: 5000), LatencyLayout.maxRadius, accuracy: 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMonotonicAndWithinBounds() {
|
||||||
|
var last = LatencyLayout.radius(forRTT: 0)
|
||||||
|
for rtt in stride(from: 5.0, through: 250.0, by: 5) {
|
||||||
|
let r = LatencyLayout.radius(forRTT: rtt)
|
||||||
|
XCTAssertGreaterThan(r, last) // farther RTT -> farther out
|
||||||
|
XCTAssertLessThanOrEqual(r, LatencyLayout.maxRadius)
|
||||||
|
last = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNearRangeSpreadsOut() {
|
||||||
|
// The log curve should give the 1–50ms band real separation, not a clump.
|
||||||
|
let r10 = LatencyLayout.radius(forRTT: 10)
|
||||||
|
let r50 = LatencyLayout.radius(forRTT: 50)
|
||||||
|
XCTAssertGreaterThan(r50 - r10, 40) // meaningfully far apart on screen
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import XCTest
|
||||||
|
import CoreGraphics
|
||||||
|
@testable import NetworkCityCore
|
||||||
|
|
||||||
|
final class LayoutSolverTests: XCTestCase {
|
||||||
|
|
||||||
|
// A label box centered under the node: ~160 wide, sitting below the dot.
|
||||||
|
private let label = CGRect(x: -80, y: -34, width: 160, height: 30)
|
||||||
|
|
||||||
|
private func worldBox(at c: CGPoint) -> CGRect { label.offsetBy(dx: c.x, dy: c.y) }
|
||||||
|
|
||||||
|
func testEmptyMapReturnsSeed() {
|
||||||
|
let p = LayoutSolver.placeNonOverlapping(baseAngle: 0, baseRadius: 200, localRect: label, existing: [])
|
||||||
|
XCTAssertEqual(p.x, 200, accuracy: 0.001) // attempt 0 == seed
|
||||||
|
XCTAssertEqual(p.y, 0, accuracy: 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAvoidsOverlapWithExisting() {
|
||||||
|
// Pre-place a box exactly where the seed would land.
|
||||||
|
let seed = CGPoint(x: cos(0.0) * 200, y: sin(0.0) * 200)
|
||||||
|
let existing = [worldBox(at: seed).insetBy(dx: -16, dy: -16)]
|
||||||
|
|
||||||
|
let p = LayoutSolver.placeNonOverlapping(baseAngle: 0, baseRadius: 200, localRect: label, existing: existing)
|
||||||
|
XCTAssertNotEqual(p, seed) // had to move
|
||||||
|
|
||||||
|
// The chosen spot's padded box must clear the existing one.
|
||||||
|
let placed = worldBox(at: p).insetBy(dx: -16, dy: -16)
|
||||||
|
XCTAssertFalse(placed.intersects(existing[0]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testResultClearsAllOfManyNeighbours() {
|
||||||
|
// Seed eight districts and confirm none of their boxes overlap.
|
||||||
|
var placed: [CGRect] = []
|
||||||
|
for i in 0..<8 {
|
||||||
|
let angle = Double(i) / 8 * 2 * .pi
|
||||||
|
let c = LayoutSolver.placeNonOverlapping(baseAngle: angle, baseRadius: 180, localRect: label, existing: placed)
|
||||||
|
placed.append(worldBox(at: c))
|
||||||
|
}
|
||||||
|
for a in 0..<placed.count {
|
||||||
|
for b in (a + 1)..<placed.count {
|
||||||
|
XCTAssertFalse(placed[a].intersects(placed[b]), "districts \(a) and \(b) overlap")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import NetworkCityCore
|
||||||
|
|
||||||
|
/// Fixtures are taken verbatim from real `nettop -L 1 -x -n -J time,bytes_in,bytes_out`
|
||||||
|
/// output captured on macOS 26, so the parser is pinned to reality.
|
||||||
|
final class NettopParserTests: XCTestCase {
|
||||||
|
|
||||||
|
let sample = """
|
||||||
|
time,,bytes_in,bytes_out,
|
||||||
|
23:49:56.574393,launchd.1,0,0,
|
||||||
|
23:49:56.573243,tcp4 127.0.0.1:8021<->*:*,,,
|
||||||
|
23:49:56.574395,apsd.571,15671580,11326466,
|
||||||
|
23:49:56.573044,tcp4 192.168.10.194:57359<->17.57.144.184:5223,15671580,11326466,
|
||||||
|
23:49:56.574397,trustd.609,10032,18469,
|
||||||
|
23:49:56.569491,quic4 192.168.10.194:51698<->17.248.242.102:443,5017,9232,
|
||||||
|
23:49:56.574410,mDNSResponder.647,974941537,77402609,
|
||||||
|
23:49:56.569906,udp6 *.5353<->*.*,176848739,34857088,
|
||||||
|
23:49:56.574316,tcp6 fe80::d08d:6eb7:1dcd:6466%utun4.1024<->fe80::9ed2:d4f7:aa69:9e47%utun4.1024,0,0,
|
||||||
|
"""
|
||||||
|
|
||||||
|
func testParsesProcessAndConnectionRows() {
|
||||||
|
let conns = NettopParser.parseConnections(sample)
|
||||||
|
// 4 connection rows present (launchd listener, apsd, trustd, mDNS, utun6)
|
||||||
|
XCTAssertEqual(conns.count, 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testConnectionInheritsPrecedingProcess() {
|
||||||
|
let conns = NettopParser.parseConnections(sample)
|
||||||
|
let apsd = conns.first { $0.remote.host == "17.57.144.184" }
|
||||||
|
XCTAssertEqual(apsd?.processName, "apsd")
|
||||||
|
XCTAssertEqual(apsd?.pid, 571)
|
||||||
|
XCTAssertEqual(apsd?.proto, .tcp4)
|
||||||
|
XCTAssertEqual(apsd?.remote.port, 5223)
|
||||||
|
XCTAssertEqual(apsd?.bytesIn, 15671580)
|
||||||
|
XCTAssertEqual(apsd?.bytesOut, 11326466)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIPv4EndpointParsing() {
|
||||||
|
let ep = NettopParser.parseEndpoint("192.168.10.194:57359", isV6: false)
|
||||||
|
XCTAssertEqual(ep.host, "192.168.10.194")
|
||||||
|
XCTAssertEqual(ep.port, 57359)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIPv6EndpointParsingStripsZone() {
|
||||||
|
let ep = NettopParser.parseEndpoint("fe80::d08d:6eb7:1dcd:6466%utun4.1024", isV6: true)
|
||||||
|
XCTAssertEqual(ep.host, "fe80::d08d:6eb7:1dcd:6466")
|
||||||
|
XCTAssertEqual(ep.port, 1024)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWildcardEndpointHasNoPort() {
|
||||||
|
XCTAssertNil(NettopParser.parseEndpoint("*:*", isV6: false).port)
|
||||||
|
XCTAssertNil(NettopParser.parseEndpoint("*.*", isV6: true).port)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testExternalClassification() {
|
||||||
|
let conns = NettopParser.parseConnections(sample)
|
||||||
|
let external = conns.filter(\.isExternal)
|
||||||
|
// Only apsd (17.57.x) and quic (17.248.x) are real off-box destinations.
|
||||||
|
// Loopback listener, mDNS wildcard, and fe80 link-local are excluded.
|
||||||
|
XCTAssertEqual(Set(external.map(\.remote.host)), ["17.57.144.184", "17.248.242.102"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testQuicProtocolRecognised() {
|
||||||
|
let conns = NettopParser.parseConnections(sample)
|
||||||
|
XCTAssertTrue(conns.contains { $0.proto == .quic4 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import NetworkCityCore
|
||||||
|
|
||||||
|
final class OrgClassifierTests: XCTestCase {
|
||||||
|
|
||||||
|
func testRegistrableDomainSimple() {
|
||||||
|
XCTAssertEqual(OrgClassifier.registrableDomain("cdn.gstatic.com"), "gstatic.com")
|
||||||
|
XCTAssertEqual(OrgClassifier.registrableDomain("a.b.c.example.org"), "example.org")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRegistrableDomainMultiPartSuffix() {
|
||||||
|
XCTAssertEqual(OrgClassifier.registrableDomain("www.bbc.co.uk"), "bbc.co.uk")
|
||||||
|
XCTAssertEqual(OrgClassifier.registrableDomain("shop.foo.com.au"), "foo.com.au")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRegistrableDomainHandlesTrailingDot() {
|
||||||
|
XCTAssertEqual(OrgClassifier.registrableDomain("host.apple.com."), "apple.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testKnownOrgsCollapseToOneDistrict() {
|
||||||
|
// Different registrable domains, same owner -> same key.
|
||||||
|
let a = OrgClassifier.classify(ip: "1", ptr: "cdn.gstatic.com")
|
||||||
|
let b = OrgClassifier.classify(ip: "2", ptr: "lb.1e100.net")
|
||||||
|
XCTAssertEqual(a.key, "google")
|
||||||
|
XCTAssertEqual(b.key, "google")
|
||||||
|
XCTAssertEqual(a.name, "Google")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAppleFamilyCollapses() {
|
||||||
|
XCTAssertEqual(OrgClassifier.classify(ip: "1", ptr: "x.icloud.com").key, "apple")
|
||||||
|
XCTAssertEqual(OrgClassifier.classify(ip: "2", ptr: "y.aaplimg.com").key, "apple")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUnknownDomainGetsPrettyName() {
|
||||||
|
let org = OrgClassifier.classify(ip: "9.9.9.9", ptr: "resolver.quad9.net")
|
||||||
|
XCTAssertEqual(org.key, "quad9.net")
|
||||||
|
XCTAssertEqual(org.name, "Quad9")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNoPTRFallsBackToIP() {
|
||||||
|
let org = OrgClassifier.classify(ip: "203.0.113.7", ptr: nil)
|
||||||
|
XCTAssertEqual(org.key, "203.0.113.7")
|
||||||
|
XCTAssertEqual(org.name, "203.0.113.7")
|
||||||
|
XCTAssertNil(org.resolvedHost)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import NetworkCityCore
|
||||||
|
|
||||||
|
final class TrafficClassTests: XCTestCase {
|
||||||
|
|
||||||
|
func testQuicByProtocol() {
|
||||||
|
XCTAssertEqual(TrafficClass.classify(proto: .quic4, port: 443), .quic)
|
||||||
|
XCTAssertEqual(TrafficClass.classify(proto: .quic6, port: 12345), .quic)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHTTPSOverTCP() {
|
||||||
|
XCTAssertEqual(TrafficClass.classify(proto: .tcp4, port: 443), .https)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUDP443IsQuic() {
|
||||||
|
XCTAssertEqual(TrafficClass.classify(proto: .udp4, port: 443), .quic)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDNS() {
|
||||||
|
XCTAssertEqual(TrafficClass.classify(proto: .udp4, port: 53), .dns)
|
||||||
|
XCTAssertEqual(TrafficClass.classify(proto: .udp6, port: 5353), .dns)
|
||||||
|
XCTAssertEqual(TrafficClass.classify(proto: .tcp4, port: 53), .dns)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHTTP() {
|
||||||
|
XCTAssertEqual(TrafficClass.classify(proto: .tcp4, port: 80), .http)
|
||||||
|
XCTAssertEqual(TrafficClass.classify(proto: .tcp4, port: 8080), .http)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOther() {
|
||||||
|
XCTAssertEqual(TrafficClass.classify(proto: .tcp4, port: 22), .other)
|
||||||
|
XCTAssertEqual(TrafficClass.classify(proto: .tcp4, port: nil), .other)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import NetworkCityCore
|
||||||
|
|
||||||
|
final class TrafficDifferTests: XCTestCase {
|
||||||
|
|
||||||
|
private func conn(_ host: String, in bIn: UInt64, out bOut: UInt64) -> Connection {
|
||||||
|
Connection(
|
||||||
|
proto: .tcp4,
|
||||||
|
local: Endpoint(host: "192.168.1.2", port: 5000),
|
||||||
|
remote: Endpoint(host: host, port: 443),
|
||||||
|
bytesIn: bIn, bytesOut: bOut,
|
||||||
|
processName: "test", pid: 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFirstSnapshotYieldsNoDeltas() {
|
||||||
|
let differ = TrafficDiffer()
|
||||||
|
let t0 = Date(timeIntervalSince1970: 1000)
|
||||||
|
let deltas = differ.ingest(ConnectionSnapshot(timestamp: t0, connections: [conn("1.1.1.1", in: 100, out: 50)]))
|
||||||
|
XCTAssertTrue(deltas.isEmpty) // need two readings to know a rate
|
||||||
|
}
|
||||||
|
|
||||||
|
func testComputesDeltaAndRate() {
|
||||||
|
let differ = TrafficDiffer()
|
||||||
|
let t0 = Date(timeIntervalSince1970: 1000)
|
||||||
|
let t1 = Date(timeIntervalSince1970: 1002) // +2s
|
||||||
|
_ = differ.ingest(ConnectionSnapshot(timestamp: t0, connections: [conn("1.1.1.1", in: 100, out: 50)]))
|
||||||
|
let deltas = differ.ingest(ConnectionSnapshot(timestamp: t1, connections: [conn("1.1.1.1", in: 1124, out: 50)]))
|
||||||
|
|
||||||
|
XCTAssertEqual(deltas.count, 1)
|
||||||
|
XCTAssertEqual(deltas[0].bytesInDelta, 1024)
|
||||||
|
XCTAssertEqual(deltas[0].bytesOutDelta, 0)
|
||||||
|
XCTAssertEqual(deltas[0].inBytesPerSec, 512, accuracy: 0.001) // 1024 / 2s
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCounterResetTreatedAsZero() {
|
||||||
|
let differ = TrafficDiffer()
|
||||||
|
let t0 = Date(timeIntervalSince1970: 1000)
|
||||||
|
let t1 = Date(timeIntervalSince1970: 1002)
|
||||||
|
_ = differ.ingest(ConnectionSnapshot(timestamp: t0, connections: [conn("1.1.1.1", in: 9999, out: 0)]))
|
||||||
|
let deltas = differ.ingest(ConnectionSnapshot(timestamp: t1, connections: [conn("1.1.1.1", in: 10, out: 0)]))
|
||||||
|
XCTAssertTrue(deltas.isEmpty) // reused tuple, counter dropped -> no phantom delta
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user