Beides läuft nur, solange jemand hinsieht. Die Prozessliste startet je Messung einen Fremdprozess — das im Sekundentakt zu tun kostete mehr als der gesamte übrige Monitor. Sie wird deshalb an das offene Popover gekoppelt, nicht an die Messschleife. Für den Durchsatz je Programm gibt es keine Bibliotheksschnittstelle: die zugrundeliegende NetworkStatistics-API ist privat und verlangt eine Entitlement, die Apple nicht vergibt. nettop ist Apples eigenes Werkzeug dafür, liegt im System und läuft — nachgemessen — ohne root für die Prozesse des eigenen Nutzers. Die Rate entsteht wie bei den Interface-Zählern aus der Differenz zweier Messungen. Die öffentliche IP bleibt standardmäßig aus, und der Schalter dafür steht im Popover statt versteckt in den Einstellungen: wer einen Systemmonitor benutzt, rechnet nicht damit, dass der nach draußen telefoniert. Ist sie an, steht der befragte Dienst sichtbar darunter — man soll nachlesen können, wem die Adresse bekannt wird. Höchstens alle 15 Minuten, weil sie sich selten ändert. Frequenzen sind der dritte Wunsch und der aufwendigste. Der Weg ist verifiziert: IOReport ist vollständig verfügbar, die Gruppe "CPU Stats" liefert unter "CPU Core Performance States" die Kanäle MCPU00 bis MCPU14 mit den P-Zustands-Residenzen, und die Frequenztabellen stehen als voltage-states* im IODeviceTree. Das kommt als Nächstes. 145 Tests grün.
244 lines
8.2 KiB
Swift
244 lines
8.2 KiB
Swift
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"
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|