diff --git a/Packages/OnyxKit/Sources/NetworkProvider/Localizable.xcstrings b/Packages/OnyxKit/Sources/NetworkProvider/Localizable.xcstrings index dd5cd0d..13f22ad 100644 --- a/Packages/OnyxKit/Sources/NetworkProvider/Localizable.xcstrings +++ b/Packages/OnyxKit/Sources/NetworkProvider/Localizable.xcstrings @@ -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" diff --git a/Packages/OnyxKit/Sources/NetworkProvider/NetworkExtras.swift b/Packages/OnyxKit/Sources/NetworkProvider/NetworkExtras.swift new file mode 100644 index 0000000..09cba66 --- /dev/null +++ b/Packages/OnyxKit/Sources/NetworkProvider/NetworkExtras.swift @@ -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[.. 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 + } +} diff --git a/Packages/OnyxKit/Sources/NetworkProvider/NetworkPopover.swift b/Packages/OnyxKit/Sources/NetworkProvider/NetworkPopover.swift index b5aa9e6..830acb3 100644 --- a/Packages/OnyxKit/Sources/NetworkProvider/NetworkPopover.swift +++ b/Packages/OnyxKit/Sources/NetworkProvider/NetworkPopover.swift @@ -45,16 +45,56 @@ struct NetworkPopoverContent: View { field("network.total.received", bytes(model.snapshot.totalReceived)) field("network.total.sent", bytes(model.snapshot.totalSent)) - // Kein Durchsatz je Programm: dafür gibt es ohne root keine - // Schnittstelle, und `nettop` im Sekundentakt zu starten kostete - // mehr als der gesamte übrige Monitor. - Text("network.perProcess.unavailable", bundle: .module) - .font(.caption2) - .foregroundStyle(.tertiary) - .fixedSize(horizontal: false, vertical: true) + section("network.public") + if model.showsPublicAddress { + HStack { + if model.publicAddress.isLoading { + ProgressView().controlSize(.small) + } else if let address = model.publicAddress.address { + 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) - .frame(width: 280) + .frame(width: 300) + .onAppear { model.addDetailConsumer() } + .onDisappear { model.removeDetailConsumer() } } private var connectionName: String { diff --git a/Packages/OnyxKit/Sources/NetworkProvider/NetworkWidget.swift b/Packages/OnyxKit/Sources/NetworkProvider/NetworkWidget.swift index 7636826..ab8d89c 100644 --- a/Packages/OnyxKit/Sources/NetworkProvider/NetworkWidget.swift +++ b/Packages/OnyxKit/Sources/NetworkProvider/NetworkWidget.swift @@ -14,11 +14,60 @@ public final class NetworkModel { 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() {} + 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 { @@ -51,6 +100,7 @@ public final class NetworkModel { private func sample() { let value = source.sample() snapshot = value + sampleProcesses() history.append(value) if history.count > Self.historyLength { history.removeFirst(history.count - Self.historyLength)