import SwiftUI import OnyxDesign import OnyxWidgetKit import MetricsProvider /// Wie `MetricsModel`, aber fürs Netz: eine Messschleife für Panel und /// Menüleiste, mit Referenzzählung. @MainActor @Observable public final class NetworkModel { public private(set) var snapshot = NetworkSnapshot() public private(set) var history: [NetworkSnapshot] = [] public static let historyLength = 60 /// Durchsatz je Programm — nur gefüllt, solange ein Popover offen ist. public private(set) var processes: [ProcessTraffic] = [] /// Die öffentliche Adresse, wenn eingeschaltet. public let publicAddress = PublicAddressLookup() /// Ob die öffentliche Adresse überhaupt abgefragt werden darf. public var showsPublicAddress: Bool { didSet { guard showsPublicAddress != oldValue else { return } defaults.set(showsPublicAddress, forKey: Self.publicAddressKey) showsPublicAddress ? publicAddress.refresh() : publicAddress.clear() } } private static let publicAddressKey = "onyx.network.showsPublicAddress" private let defaults: UserDefaults private let source = NetworkMetrics() private let processSampler = ProcessTrafficSampler() private var timer: Timer? private var demands: [UUID: TimeInterval] = [:] /// Zählt die offenen Popovers. Die Prozessliste kostet einen Fremdprozess /// je Messung — sie läuft nur, während wirklich jemand hinsieht. private var detailConsumers = 0 public init(defaults: UserDefaults = .standard) { self.defaults = defaults self.showsPublicAddress = defaults.bool(forKey: Self.publicAddressKey) } /// Meldet ein offenes Popover an. Erst dann werden Prozesse abgefragt. public func addDetailConsumer() { detailConsumers += 1 if showsPublicAddress { publicAddress.refresh() } sampleProcesses() } public func removeDetailConsumer() { detailConsumers = max(detailConsumers - 1, 0) if detailConsumers == 0 { processes = [] } } private func sampleProcesses() { guard detailConsumers > 0 else { return } // `nettop` blockiert kurz — nicht auf dem Hauptthread. let sampler = processSampler Task.detached(priority: .utility) { let result = sampler.sample() await MainActor.run { [weak self] in guard let self, detailConsumers > 0 else { return } processes = result } } } @discardableResult public func addConsumer(interval: TimeInterval = 2) -> UUID { let token = UUID() demands[token] = min(max(interval, 1), 10) restartTimer() return token } public func removeConsumer(_ token: UUID) { demands[token] = nil restartTimer() } private func restartTimer() { timer?.invalidate() timer = nil guard let interval = demands.values.min() else { history.removeAll() return } sample() let timer = Timer(timeInterval: interval, repeats: true) { _ in MainActor.assumeIsolated { [weak self] in self?.sample() } } RunLoop.main.add(timer, forMode: .common) self.timer = timer } private func sample() { let value = source.sample() snapshot = value sampleProcesses() history.append(value) if history.count > Self.historyLength { history.removeFirst(history.count - Self.historyLength) } } /// Verläufe, auf den bisherigen Höchstwert normiert. /// /// Eine feste Obergrenze wäre unbrauchbar: bei 100 Mbit/s wäre eine /// Video-Wiedergabe ein flacher Strich, und bei 1 Mbit/s ginge jeder /// Download über den Rand. Die Kurve zeigt deshalb den Verlauf relativ zum /// bisherigen Maximum — die absoluten Zahlen stehen daneben. public func series(download: Bool) -> [Double] { let values = history.map { download ? $0.downloadRate : $0.uploadRate } guard let peak = values.max(), peak > 0 else { return values.map { _ in 0 } } return values.map { $0 / peak } } } // MARK: - Widget public struct NetworkWidget: OnyxWidget { public let id = "network" public var displayName: String { String(localized: "network.name", bundle: .module) } public let symbolName = "network" private let model: NetworkModel public init(model: NetworkModel) { self.model = model } public func makeView() -> AnyView { AnyView(NetworkWidgetView(model: model)) } } private struct NetworkWidgetView: View { let model: NetworkModel @State private var token: UUID? var body: some View { VStack(alignment: .leading, spacing: 5) { HStack(spacing: 6) { Image(systemName: model.snapshot.interfaceKind.symbolName) .font(.system(size: 11)) .foregroundStyle(Onyx.Color.accent) Text(connectionName) .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.textSecondary) .lineLimit(1) } Rates(snapshot: model.snapshot, compact: false) Sparkline(values: model.series(download: true), tint: Onyx.Color.accent, capacity: NetworkModel.historyLength) .frame(height: 18) Divider().overlay(Onyx.Color.hairline) Details(snapshot: model.snapshot) Spacer(minLength: 0) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .onAppear { token = model.addConsumer(interval: 1) } .onDisappear { if let token { model.removeConsumer(token) } token = nil } } /// Der Netzwerkname, wenn macOS ihn herausgibt — sonst die Verbindungsart. /// /// Seit macOS 14 liefert die SSID nur mit Ortungsberechtigung einen Wert. /// Fehlt sie, wäre ein leeres Feld eine Falschaussage: verbunden ist man ja. private var connectionName: String { if let ssid = model.snapshot.ssid { return ssid } if model.snapshot.isWiFiConnected { return String(localized: "network.wifi.connected", bundle: .module) } return model.snapshot.interfaceName ?? String(localized: "network.offline", bundle: .module) } } private struct Rates: View { let snapshot: NetworkSnapshot let compact: Bool var body: some View { VStack(alignment: .leading, spacing: 1) { row("arrow.down", Throughput.formatted(snapshot.downloadRate), Onyx.Color.accent) row("arrow.up", Throughput.formatted(snapshot.uploadRate), Onyx.Color.positive) } } private func row(_ symbol: String, _ text: String, _ tint: Color) -> some View { HStack(spacing: 3) { Image(systemName: symbol) .font(.system(size: 8, weight: .bold)) .foregroundStyle(tint) Text(text) .font(compact ? Onyx.Font.metricSmall : Onyx.Font.metricSmall) .foregroundStyle(Onyx.Color.textPrimary) .lineLimit(1) } } } private struct Details: View { let snapshot: NetworkSnapshot var body: some View { VStack(alignment: .leading, spacing: 2) { field("network.field.interface", snapshot.interfaceName) field("network.field.ipv4", snapshot.ipv4) field("network.field.router", snapshot.router) } } @ViewBuilder private func field(_ label: LocalizedStringKey, _ value: String?) -> some View { if let value { HStack(spacing: 4) { Text(label, bundle: .module) .font(.system(size: 9)) .foregroundStyle(Onyx.Color.textTertiary) Text(value) .font(.system(size: 9)) .monospacedDigit() .foregroundStyle(Onyx.Color.textSecondary) .textSelection(.enabled) .lineLimit(1) } } } }