import Foundation import Darwin import SystemConfiguration import CoreWLAN import MetricsProvider public struct NetworkSnapshot: Equatable, Sendable { /// Byte pro Sekunde. public var downloadRate: Double = 0 public var uploadRate: Double = 0 /// Summen seit dem Systemstart. public var totalReceived: UInt64 = 0 public var totalSent: UInt64 = 0 public var interfaceName: String? public var interfaceKind: InterfaceKind = .unknown public var ipv4: String? public var ipv6: String? public var router: String? /// `nil`, solange die Ortungsberechtigung fehlt — macOS gibt den Namen /// sonst nicht heraus. Siehe `wifiName`. public var ssid: String? public var isWiFiConnected = false public var asOf = Date() } public enum InterfaceKind: String, Equatable, Sendable { case wifi, ethernet, vpn, cellular, loopback, unknown public var symbolName: String { switch self { case .wifi: "wifi" case .ethernet: "cable.connector" case .vpn: "lock.shield" case .cellular: "antenna.radiowaves.left.and.right" case .loopback, .unknown: "network" } } } /// Liest Durchsatz, Adressen und Verbindungsart. /// /// Alles ohne root und ohne private Schnittstellen. Der Durchsatz kommt aus den /// Byte-Zählern des Kernels; sie sind Summen seit dem Start, die Rate ergibt /// sich erst aus der Differenz — siehe `Throughput.rate`. public final class NetworkMetrics: @unchecked Sendable { private var previousReceived: UInt64 = 0 private var previousSent: UInt64 = 0 private var previousSampleTime: Date? public init() {} public func sample() -> NetworkSnapshot { var snapshot = NetworkSnapshot() let counters = readCounters() snapshot.totalReceived = counters.received snapshot.totalSent = counters.sent let now = Date() if let previous = previousSampleTime { let elapsed = now.timeIntervalSince(previous) snapshot.downloadRate = Throughput.rate(previous: previousReceived, current: counters.received, elapsed: elapsed) snapshot.uploadRate = Throughput.rate(previous: previousSent, current: counters.sent, elapsed: elapsed) } previousReceived = counters.received previousSent = counters.sent previousSampleTime = now let primary = primaryInterface() snapshot.interfaceName = primary.name snapshot.interfaceKind = primary.kind snapshot.router = primary.router let addresses = localAddresses(for: primary.name) snapshot.ipv4 = addresses.ipv4 snapshot.ipv6 = addresses.ipv6 snapshot.isWiFiConnected = primary.kind == .wifi snapshot.ssid = wifiName() return snapshot } // MARK: - Durchsatz /// Summiert alle Interfaces außer Loopback. /// /// Einzeln zu zählen wäre genauer, aber praktisch nutzlos: bei einem /// Wechsel von WLAN auf VPN wandert der Verkehr auf ein anderes Interface, /// und eine Anzeige, die dabei auf null fällt, ist falsch. private func readCounters() -> (received: UInt64, sent: UInt64) { var mib: [Int32] = [CTL_NET, PF_ROUTE, 0, 0, NET_RT_IFLIST2, 0] var length = 0 guard sysctl(&mib, 6, nil, &length, nil, 0) == 0, length > 0 else { return (0, 0) } var buffer = [UInt8](repeating: 0, count: length) guard sysctl(&mib, 6, &buffer, &length, nil, 0) == 0 else { return (0, 0) } var received: UInt64 = 0 var sent: UInt64 = 0 buffer.withUnsafeBytes { raw in var offset = 0 while offset < length { let header = raw.baseAddress!.advanced(by: offset) .assumingMemoryBound(to: if_msghdr.self).pointee guard header.ifm_msglen > 0 else { break } defer { offset += Int(header.ifm_msglen) } guard header.ifm_type == RTM_IFINFO2 else { continue } let message = raw.baseAddress!.advanced(by: offset) .assumingMemoryBound(to: if_msghdr2.self).pointee // Loopback ausschließen: der lokale Verkehr zwischen Programmen // hat mit „was geht über die Leitung" nichts zu tun und kann // ein Vielfaches davon sein. guard message.ifm_data.ifi_type != UInt8(IFT_LOOP) else { continue } received += message.ifm_data.ifi_ibytes sent += message.ifm_data.ifi_obytes } } return (received, sent) } // MARK: - Interface und Adressen private struct Primary { var name: String? var kind: InterfaceKind = .unknown var router: String? } private func primaryInterface() -> Primary { var primary = Primary() guard let store = SCDynamicStoreCreate(nil, "Onyx" as CFString, nil, nil), let global = SCDynamicStoreCopyValue(store, "State:/Network/Global/IPv4" as CFString) as? [String: Any] else { return primary } primary.name = global["PrimaryInterface"] as? String primary.router = global["Router"] as? String primary.kind = kind(of: primary.name) return primary } private func kind(of name: String?) -> InterfaceKind { guard let name else { return .unknown } // Die Namensschemata sind stabil: en0/en1 sind Ethernet oder WLAN, // utun/ipsec/ppp gehören zu VPNs, lo0 ist die Loopback-Schnittstelle. if name.hasPrefix("utun") || name.hasPrefix("ipsec") || name.hasPrefix("ppp") { return .vpn } if name.hasPrefix("lo") { return .loopback } if name.hasPrefix("pdp_ip") { return .cellular } if name.hasPrefix("en") { // en0 ist auf Notebooks das WLAN, aber nicht zwingend — CoreWLAN // weiß es genau. let wifiNames = CWWiFiClient.interfaceNames() ?? [] return wifiNames.contains(name) ? .wifi : .ethernet } return .unknown } private func localAddresses(for interface: String?) -> (ipv4: String?, ipv6: String?) { guard let interface else { return (nil, nil) } var pointer: UnsafeMutablePointer? guard getifaddrs(&pointer) == 0, let first = pointer else { return (nil, nil) } defer { freeifaddrs(pointer) } var ipv4: String? var ipv6: String? for entry in sequence(first: first, next: { $0.pointee.ifa_next }) { guard String(cString: entry.pointee.ifa_name) == interface, let address = entry.pointee.ifa_addr else { continue } var host = [CChar](repeating: 0, count: Int(NI_MAXHOST)) guard getnameinfo(address, socklen_t(address.pointee.sa_len), &host, socklen_t(host.count), nil, 0, NI_NUMERICHOST) == 0 else { continue } let text = String(cString: host) switch Int32(address.pointee.sa_family) { case AF_INET where ipv4 == nil: ipv4 = text case AF_INET6 where ipv6 == nil: // Link-lokale Adressen (fe80::) tragen einen Zonen-Anhang und // sagen dem Nutzer nichts. if !text.hasPrefix("fe80") { ipv6 = text } default: break } } return (ipv4, ipv6) } // MARK: - WLAN /// Der Netzwerkname — oder `nil`, wenn macOS ihn nicht herausgibt. /// /// Seit macOS 14 liefert `ssid()` nur mit erteilter Ortungsberechtigung /// einen Wert. Fehlt sie, kommt `nil` zurück, **ohne** dass ein Fehler /// erscheint. Deshalb wird hier nicht geraten: die Oberfläche zeigt dann /// „WLAN verbunden" statt eines leeren Feldes, das nach einem Defekt /// aussieht. private func wifiName() -> String? { CWWiFiClient.shared().interface()?.ssid() } }