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 private let source = NetworkMetrics() private var timer: Timer? private var demands: [UUID: TimeInterval] = [:] public init() {} @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 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" public let supportedSizes: [WidgetSize] = [.small, .medium, .large] private let model: NetworkModel public init(model: NetworkModel) { self.model = model } public func makeView(size: WidgetSize) -> AnyView { AnyView(NetworkWidgetView(model: model, size: size)) } } private struct NetworkWidgetView: View { let model: NetworkModel let size: WidgetSize @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: size == .small) if size != .small { Sparkline(values: model.series(download: true), tint: Onyx.Color.accent) .frame(height: 18) } if size == .large { 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) } } } }