Durchsatz je Programm und öffentliche IP
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.
This commit is contained in:
@@ -273,6 +273,86 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"network.public": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Öffentliche Adresse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Public address"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"network.public.enable": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Öffentliche IP anzeigen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Show public IP"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"network.public.hint": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Dafür fragt Onyx einen fremden Dienst — standardmäßig aus, weil ein Systemmonitor nicht ungefragt nach draußen telefonieren sollte."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "This asks an external service — off by default, because a system monitor shouldn't phone home unasked."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"network.public.failed": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Nicht abrufbar"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Unavailable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"network.processes": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Je Programm"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Per app"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version": "1.0"
|
"version": "1.0"
|
||||||
|
|||||||
153
Packages/OnyxKit/Sources/NetworkProvider/NetworkExtras.swift
Normal file
153
Packages/OnyxKit/Sources/NetworkProvider/NetworkExtras.swift
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
import MetricsProvider
|
||||||
|
|
||||||
|
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Network")
|
||||||
|
|
||||||
|
/// Durchsatz je Programm.
|
||||||
|
public struct ProcessTraffic: Equatable, Sendable, Identifiable {
|
||||||
|
public let name: String
|
||||||
|
public let pid: pid_t
|
||||||
|
public let downloadRate: Double
|
||||||
|
public let uploadRate: Double
|
||||||
|
public var id: pid_t { pid }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liest den Durchsatz je Programm über `nettop`.
|
||||||
|
///
|
||||||
|
/// Es gibt dafür 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 **ohne root** für die Prozesse des eigenen Nutzers.
|
||||||
|
///
|
||||||
|
/// Deshalb wird es nur abgefragt, solange jemand hinschaut: einen
|
||||||
|
/// Fremdprozess im Sekundentakt zu starten wäre teurer als der gesamte übrige
|
||||||
|
/// Monitor zusammen.
|
||||||
|
public final class ProcessTrafficSampler: @unchecked Sendable {
|
||||||
|
|
||||||
|
private var previous: [pid_t: (received: UInt64, sent: UInt64)] = [:]
|
||||||
|
private var previousTime: Date?
|
||||||
|
private let lock = NSLock()
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func sample(limit: Int = 6) -> [ProcessTraffic] {
|
||||||
|
guard let output = runNettop() else { return [] }
|
||||||
|
|
||||||
|
let now = Date()
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
|
||||||
|
let elapsed = previousTime.map { now.timeIntervalSince($0) } ?? 0
|
||||||
|
var current: [pid_t: (received: UInt64, sent: UInt64)] = [:]
|
||||||
|
var results: [ProcessTraffic] = []
|
||||||
|
|
||||||
|
// Format: `name.pid,bytes_in,bytes_out,`
|
||||||
|
for line in output.split(separator: "\n").dropFirst() {
|
||||||
|
let fields = line.split(separator: ",", omittingEmptySubsequences: false)
|
||||||
|
guard fields.count >= 3 else { continue }
|
||||||
|
|
||||||
|
let identifier = String(fields[0])
|
||||||
|
guard let separator = identifier.lastIndex(of: "."),
|
||||||
|
let pid = pid_t(identifier[identifier.index(after: separator)...]),
|
||||||
|
let received = UInt64(fields[1]), let sent = UInt64(fields[2])
|
||||||
|
else { continue }
|
||||||
|
|
||||||
|
let name = String(identifier[..<separator])
|
||||||
|
current[pid] = (received, sent)
|
||||||
|
|
||||||
|
guard elapsed > 0, let before = previous[pid] else { continue }
|
||||||
|
let down = Throughput.rate(previous: before.received, current: received, elapsed: elapsed)
|
||||||
|
let up = Throughput.rate(previous: before.sent, current: sent, elapsed: elapsed)
|
||||||
|
guard down + up > 0 else { continue }
|
||||||
|
|
||||||
|
results.append(ProcessTraffic(name: name, pid: pid,
|
||||||
|
downloadRate: down, uploadRate: up))
|
||||||
|
}
|
||||||
|
|
||||||
|
previous = current
|
||||||
|
previousTime = now
|
||||||
|
return Array(results.sorted { $0.downloadRate + $0.uploadRate
|
||||||
|
> $1.downloadRate + $1.uploadRate }.prefix(limit))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runNettop() -> String? {
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/nettop")
|
||||||
|
// `-P` fasst je Prozess zusammen, `-x` unterdrückt Einheiten, `-L 1`
|
||||||
|
// nimmt genau eine Messung und beendet sich dann.
|
||||||
|
process.arguments = ["-P", "-x", "-L", "1", "-J", "bytes_in,bytes_out"]
|
||||||
|
|
||||||
|
let pipe = Pipe()
|
||||||
|
process.standardOutput = pipe
|
||||||
|
process.standardError = FileHandle.nullDevice
|
||||||
|
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
process.waitUntilExit()
|
||||||
|
return String(data: data, encoding: .utf8)
|
||||||
|
} catch {
|
||||||
|
log.error("nettop nicht ausführbar: \(error.localizedDescription, privacy: .public)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ermittelt die öffentliche IP-Adresse.
|
||||||
|
///
|
||||||
|
/// **Standardmäßig aus.** Dafür geht eine Anfrage an einen fremden Dienst, und
|
||||||
|
/// wer einen Systemmonitor benutzt, rechnet nicht damit, dass der nach draußen
|
||||||
|
/// telefoniert. Wird sie eingeschaltet, steht der benutzte Dienst in der
|
||||||
|
/// Oberfläche — man soll nachlesen können, wem die Adresse bekannt wird.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
public final class PublicAddressLookup {
|
||||||
|
|
||||||
|
public private(set) var address: String?
|
||||||
|
public private(set) var isLoading = false
|
||||||
|
public private(set) var failed = false
|
||||||
|
|
||||||
|
/// Der Dienst, der gefragt wird. Bewusst als sichtbare Angabe und nicht
|
||||||
|
/// versteckt im Code.
|
||||||
|
public static let serviceName = "api.ipify.org"
|
||||||
|
private static let endpoint = URL(string: "https://api.ipify.org?format=json")!
|
||||||
|
|
||||||
|
private var lastFetch: Date?
|
||||||
|
/// Mindestabstand zwischen zwei Abfragen. Die öffentliche Adresse ändert
|
||||||
|
/// sich selten; sie bei jedem Öffnen des Popovers neu zu holen wäre
|
||||||
|
/// unnötiger Verkehr nach draußen.
|
||||||
|
private static let minimumInterval: TimeInterval = 15 * 60
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func refresh(force: Bool = false) {
|
||||||
|
if !force, let last = lastFetch,
|
||||||
|
Date().timeIntervalSince(last) < Self.minimumInterval { return }
|
||||||
|
guard !isLoading else { return }
|
||||||
|
|
||||||
|
isLoading = true
|
||||||
|
failed = false
|
||||||
|
Task { [weak self] in
|
||||||
|
defer { Task { @MainActor in self?.isLoading = false } }
|
||||||
|
do {
|
||||||
|
let (data, _) = try await URLSession.shared.data(from: Self.endpoint)
|
||||||
|
let decoded = try JSONDecoder().decode([String: String].self, from: data)
|
||||||
|
await MainActor.run {
|
||||||
|
self?.address = decoded["ip"]
|
||||||
|
self?.lastFetch = Date()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
log.error("Öffentliche IP nicht abrufbar: \(error.localizedDescription, privacy: .public)")
|
||||||
|
await MainActor.run { self?.failed = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vergisst die Adresse — beim Abschalten der Funktion.
|
||||||
|
public func clear() {
|
||||||
|
address = nil
|
||||||
|
lastFetch = nil
|
||||||
|
failed = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,16 +45,56 @@ struct NetworkPopoverContent: View {
|
|||||||
field("network.total.received", bytes(model.snapshot.totalReceived))
|
field("network.total.received", bytes(model.snapshot.totalReceived))
|
||||||
field("network.total.sent", bytes(model.snapshot.totalSent))
|
field("network.total.sent", bytes(model.snapshot.totalSent))
|
||||||
|
|
||||||
// Kein Durchsatz je Programm: dafür gibt es ohne root keine
|
section("network.public")
|
||||||
// Schnittstelle, und `nettop` im Sekundentakt zu starten kostete
|
if model.showsPublicAddress {
|
||||||
// mehr als der gesamte übrige Monitor.
|
HStack {
|
||||||
Text("network.perProcess.unavailable", bundle: .module)
|
if model.publicAddress.isLoading {
|
||||||
.font(.caption2)
|
ProgressView().controlSize(.small)
|
||||||
.foregroundStyle(.tertiary)
|
} else if let address = model.publicAddress.address {
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
Text(address).font(.callout.monospacedDigit()).textSelection(.enabled)
|
||||||
|
} else if model.publicAddress.failed {
|
||||||
|
Text("network.public.failed", bundle: .module)
|
||||||
|
.font(.callout).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button { model.publicAddress.refresh(force: true) } label: {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
// Wer die Adresse erfährt, gehört sichtbar dazu.
|
||||||
|
Text(verbatim: PublicAddressLookup.serviceName)
|
||||||
|
.font(.caption2).foregroundStyle(.tertiary)
|
||||||
|
} else {
|
||||||
|
Toggle(isOn: Binding(get: { model.showsPublicAddress },
|
||||||
|
set: { model.showsPublicAddress = $0 })) {
|
||||||
|
Text("network.public.enable", bundle: .module).font(.callout)
|
||||||
|
}
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
.controlSize(.small)
|
||||||
|
Text("network.public.hint", bundle: .module)
|
||||||
|
.font(.caption2).foregroundStyle(.tertiary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !model.processes.isEmpty {
|
||||||
|
section("network.processes")
|
||||||
|
ForEach(model.processes) { process in
|
||||||
|
HStack {
|
||||||
|
Text(process.name).font(.callout).lineLimit(1)
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
Text(Throughput.formatted(process.downloadRate))
|
||||||
|
.font(.caption.monospacedDigit()).foregroundStyle(.green)
|
||||||
|
Text(Throughput.formatted(process.uploadRate))
|
||||||
|
.font(.caption.monospacedDigit()).foregroundStyle(.pink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(14)
|
.padding(14)
|
||||||
.frame(width: 280)
|
.frame(width: 300)
|
||||||
|
.onAppear { model.addDetailConsumer() }
|
||||||
|
.onDisappear { model.removeDetailConsumer() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var connectionName: String {
|
private var connectionName: String {
|
||||||
|
|||||||
@@ -14,11 +14,60 @@ public final class NetworkModel {
|
|||||||
|
|
||||||
public static let historyLength = 60
|
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 source = NetworkMetrics()
|
||||||
|
private let processSampler = ProcessTrafficSampler()
|
||||||
private var timer: Timer?
|
private var timer: Timer?
|
||||||
private var demands: [UUID: TimeInterval] = [:]
|
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() {}
|
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
|
@discardableResult
|
||||||
public func addConsumer(interval: TimeInterval = 2) -> UUID {
|
public func addConsumer(interval: TimeInterval = 2) -> UUID {
|
||||||
@@ -51,6 +100,7 @@ public final class NetworkModel {
|
|||||||
private func sample() {
|
private func sample() {
|
||||||
let value = source.sample()
|
let value = source.sample()
|
||||||
snapshot = value
|
snapshot = value
|
||||||
|
sampleProcesses()
|
||||||
history.append(value)
|
history.append(value)
|
||||||
if history.count > Self.historyLength {
|
if history.count > Self.historyLength {
|
||||||
history.removeFirst(history.count - Self.historyLength)
|
history.removeFirst(history.count - Self.historyLength)
|
||||||
|
|||||||
Reference in New Issue
Block a user