Calendarr liefert Termine über den Gruppencontainer. Streng lesend: keine eigene Anmeldung, kein Netz, kein Schreiben zurück. Die Abdeckung ist der Kern und kein Detail. Der Schnappschuss reicht 49 Tage nach vorne, und das steht nirgends in der Datei — es ist zugesagtes Verhalten des Schreibers. Außerhalb liegt keine Information vor, nicht keine Termine; das Monatsraster graut solche Tage aus, statt „frei" zu behaupten. Verlockend wäre, die Abdeckung aus dem frühesten Termin abzuleiten. Das ist falsch, und ein Test hält es fest: mehrtägige Termine ragen ins Fenster hinein und werden mitgeschrieben, obwohl ihr Anfang nicht abgedeckt ist. Bei „beide Quellen" ist die Abdeckung der Schnitt, nicht die Vereinigung. Ein Tag gilt nur als bekannt, wenn ihn jede Quelle abdeckt. Umgekehrt darf ein Ausfall einer Quelle die andere nicht mitreißen: Calendarr nie geöffnet zu haben knipst den Apple-Kalender nicht aus. Die Ablage kopiert, statt zu verweisen. Anhänge aus Mail oder WhatsApp liegen in temporären Verzeichnissen — ein Verweis wäre in Stunden tot, und die Ablage wäre leer, wenn man sie braucht. Aus demselben Grund nimmt die Fläche UTType.item statt public.file-url an: solche Anhänge kommen als Dateiversprechen, und loadFileRepresentation löst beides ein. Die übergebene Datei existiert nur innerhalb des Rückrufs, also wird sie dort sofort gesichert statt später auf dem MainActor. Gelöscht wird in den Papierkorb. Automatisches Aufräumen ist standardmäßig aus — eine Ablage, die ungefragt löscht, ist ein Papierkorb mit Zeitschaltuhr. Nebenbei: „mixer.on" und „network.vpn.active" standen als rohe Schlüssel in der Oberfläche. Label(_:systemImage:) kennt kein Bundle und sucht immer im Hauptbundle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
191 lines
7.8 KiB
Swift
191 lines
7.8 KiB
Swift
import SwiftUI
|
|
import MetricsProvider
|
|
|
|
@MainActor
|
|
struct NetworkPopoverContent: View {
|
|
let model: NetworkModel
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack(alignment: .top) {
|
|
rate(model.snapshot.downloadRate, "network.download", .green)
|
|
Spacer()
|
|
rate(model.snapshot.uploadRate, "network.upload", .pink)
|
|
}
|
|
|
|
TrafficChart(history: model.history)
|
|
.frame(height: 56)
|
|
|
|
HStack {
|
|
peak(model.history.map(\.downloadRate).max() ?? 0, "network.peak.download")
|
|
Spacer()
|
|
peak(model.history.map(\.uploadRate).max() ?? 0, "network.peak.upload")
|
|
}
|
|
|
|
Divider()
|
|
|
|
Label(connectionName, systemImage: model.snapshot.interfaceKind.symbolName)
|
|
.font(.callout)
|
|
|
|
if model.snapshot.interfaceKind == .vpn {
|
|
// Ein VPN verändert, was die Zahlen darüber bedeuten — der
|
|
// Verkehr läuft dann durch den Tunnel, nicht direkt.
|
|
// Über Text statt Label(_:systemImage:): letzteres kennt kein
|
|
// Bundle und zeigt sonst den rohen Schlüssel an.
|
|
Label {
|
|
Text("network.vpn.active", bundle: .module)
|
|
} icon: {
|
|
Image(systemName: "lock.shield")
|
|
}
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
section("network.addresses")
|
|
field("network.field.interface", model.snapshot.interfaceName)
|
|
field("network.field.ipv4", model.snapshot.ipv4)
|
|
field("network.field.ipv6", model.snapshot.ipv6)
|
|
field("network.field.router", model.snapshot.router)
|
|
|
|
section("network.transferred")
|
|
field("network.total.received", bytes(model.snapshot.totalReceived))
|
|
field("network.total.sent", bytes(model.snapshot.totalSent))
|
|
|
|
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: 300)
|
|
.onAppear { model.addDetailConsumer() }
|
|
.onDisappear { model.removeDetailConsumer() }
|
|
}
|
|
|
|
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 func rate(_ value: Double, _ key: LocalizedStringKey, _ tint: Color) -> some View {
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(Throughput.formatted(value))
|
|
.font(.system(size: 18, weight: .medium).monospacedDigit())
|
|
HStack(spacing: 4) {
|
|
Circle().fill(tint).frame(width: 6, height: 6)
|
|
Text(key, bundle: .module).font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func peak(_ value: Double, _ key: LocalizedStringKey) -> some View {
|
|
HStack(spacing: 4) {
|
|
Text(key, bundle: .module).font(.caption2).foregroundStyle(.secondary)
|
|
Text(Throughput.formatted(value)).font(.caption2.monospacedDigit())
|
|
}
|
|
}
|
|
|
|
private func section(_ key: LocalizedStringKey) -> some View {
|
|
Text(key, bundle: .module)
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.tint)
|
|
.textCase(.uppercase)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func field(_ key: LocalizedStringKey, _ value: String?) -> some View {
|
|
if let value {
|
|
HStack {
|
|
Text(key, bundle: .module).font(.callout).foregroundStyle(.secondary)
|
|
Spacer(minLength: 12)
|
|
Text(value).font(.callout.monospacedDigit()).textSelection(.enabled)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func bytes(_ value: UInt64) -> String {
|
|
ByteCountFormatter.string(fromByteCount: Int64(value), countStyle: .binary)
|
|
}
|
|
}
|
|
|
|
/// Verlauf in beide Richtungen: Download nach unten, Upload nach oben.
|
|
///
|
|
/// Zwei getrennte Kurven übereinander wären platzsparender, aber schwerer zu
|
|
/// lesen — hier sieht man sofort, ob gerade geladen oder gesendet wird, und in
|
|
/// welchem Verhältnis. Beide Richtungen teilen sich denselben Maßstab, sonst
|
|
/// sähe ein Upload von 20 KB/s genauso hoch aus wie ein Download von 20 MB/s.
|
|
private struct TrafficChart: View {
|
|
let history: [NetworkSnapshot]
|
|
|
|
var body: some View {
|
|
GeometryReader { geometry in
|
|
let points = history.suffix(60)
|
|
let peak = max(points.map { max($0.downloadRate, $0.uploadRate) }.max() ?? 1, 1)
|
|
let width = geometry.size.width / CGFloat(max(points.count, 1))
|
|
let half = geometry.size.height / 2
|
|
|
|
HStack(alignment: .center, spacing: 1) {
|
|
ForEach(Array(points.enumerated()), id: \.offset) { _, snapshot in
|
|
VStack(spacing: 1) {
|
|
Rectangle()
|
|
.fill(.pink)
|
|
.frame(height: max(half * CGFloat(snapshot.uploadRate / peak), 0.5))
|
|
.frame(maxHeight: .infinity, alignment: .bottom)
|
|
Rectangle()
|
|
.fill(.green)
|
|
.frame(height: max(half * CGFloat(snapshot.downloadRate / peak), 0.5))
|
|
.frame(maxHeight: .infinity, alignment: .top)
|
|
}
|
|
.frame(width: max(width - 1, 1))
|
|
}
|
|
}
|
|
.frame(width: geometry.size.width, height: geometry.size.height)
|
|
}
|
|
}
|
|
}
|