forked from jodacame/NetSpeed
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.swift
More file actions
663 lines (588 loc) · 26.6 KB
/
Copy pathmain.swift
File metadata and controls
663 lines (588 loc) · 26.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
import Cocoa
import ServiceManagement
// MARK: - Total interface throughput (for the menu bar title)
func currentNetworkBytes() -> (down: UInt64, up: UInt64) {
var ifaddrPtr: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&ifaddrPtr) == 0, let firstAddr = ifaddrPtr else { return (0, 0) }
defer { freeifaddrs(ifaddrPtr) }
var down: UInt64 = 0
var up: UInt64 = 0
var ptr: UnsafeMutablePointer<ifaddrs>? = firstAddr
while let interface = ptr?.pointee {
defer { ptr = interface.ifa_next }
let name = String(cString: interface.ifa_name)
guard name.hasPrefix("en") || name.hasPrefix("utun") else { continue }
guard let address = interface.ifa_addr, address.pointee.sa_family == UInt8(AF_LINK) else { continue }
guard let data = interface.ifa_data else { continue }
let networkData = data.assumingMemoryBound(to: if_data.self).pointee
down += UInt64(networkData.ifi_ibytes)
up += UInt64(networkData.ifi_obytes)
}
return (down, up)
}
func formatRate(_ bytesPerSecond: UInt64) -> String {
let kb = Double(bytesPerSecond) / 1024.0
if kb < 1000 {
return String(format: "%5.1f KB/s", kb)
} else {
return String(format: "%5.1f MB/s", kb / 1024.0)
}
}
func formatRateTable(_ bytesPerSecond: UInt64) -> String {
let kb = Double(bytesPerSecond) / 1024.0
if kb < 1000 {
return String(format: "%.0f KB/s", kb)
} else {
return String(format: "%.1f MB/s", kb / 1024.0)
}
}
// MARK: - Per-app usage via nettop
private struct RawUsage {
let pid: Int32
let name: String
let bytesIn: UInt64
let bytesOut: UInt64
}
private func parseNettop(_ output: String) -> [RawUsage] {
var results: [RawUsage] = []
for line in output.split(separator: "\n") {
let cols = line.split(separator: ",", omittingEmptySubsequences: false)
guard cols.count >= 3 else { continue }
let label = String(cols[0])
guard let bytesIn = UInt64(cols[1]), let bytesOut = UInt64(cols[2]) else { continue }
guard let lastDot = label.range(of: ".", options: .backwards) else { continue }
let namePart = String(label[label.startIndex..<lastDot.lowerBound])
let pidPart = String(label[lastDot.upperBound...])
guard let pid = Int32(pidPart), !namePart.isEmpty else { continue }
results.append(RawUsage(pid: pid, name: namePart, bytesIn: bytesIn, bytesOut: bytesOut))
}
return results
}
private func runNettopSnapshot() -> String? {
let task = Process()
task.executableURL = URL(fileURLWithPath: "/usr/bin/nettop")
task.arguments = ["-P", "-L", "1", "-J", "bytes_in,bytes_out", "-x"]
let outPipe = Pipe()
task.standardOutput = outPipe
task.standardError = Pipe()
do {
try task.run()
} catch {
return nil
}
let data = outPipe.fileHandleForReading.readDataToEndOfFile()
task.waitUntilExit()
return String(data: data, encoding: .utf8)
}
// MARK: - App usage model
struct AppUsage {
let pid: Int32
let name: String
let icon: NSImage?
let downRate: UInt64
let upRate: UInt64
var downText: String { formatRateTable(downRate) }
var upText: String { formatRateTable(upRate) }
}
// Resolve bundled helpers from their executable path, including nested .app bundles.
func processExecutablePath(_ pid: Int32) -> String? {
var buffer = [CChar](repeating: 0, count: 4 * Int(MAXPATHLEN))
let count = buffer.withUnsafeMutableBytes { proc_pidpath(pid, $0.baseAddress, UInt32($0.count)) }
return count > 0 ? String(cString: buffer) : nil
}
func enclosingApplication(_ path: String) -> URL? {
var current = URL(fileURLWithPath: path)
var result: URL?
while current.path != "/" {
if current.pathExtension == "app" { result = current }
current.deleteLastPathComponent()
}
return result
}
final class ProcessIconResolver {
private var icons: [URL: NSImage] = [:]
private var extensions: [String: URL] = [:]
private var lastIndexTime: TimeInterval = -.infinity
private func indexExtensions() {
let now = ProcessInfo.processInfo.systemUptime
guard now - lastIndexTime > 60 else { return }
lastIndexTime = now
extensions = [:]
var apps = Set(NSWorkspace.shared.runningApplications.compactMap { $0.bundleURL })
for root in ["/Applications", NSHomeDirectory() + "/Applications"] {
let children = (try? FileManager.default.contentsOfDirectory(at: URL(fileURLWithPath: root),
includingPropertiesForKeys: nil)) ?? []
apps.formUnion(children.filter { $0.pathExtension == "app" })
}
for appURL in apps {
let directory = appURL.appendingPathComponent("Contents/Library/SystemExtensions")
let children = (try? FileManager.default.contentsOfDirectory(at: directory,
includingPropertiesForKeys: nil)) ?? []
for child in children {
if let identifier = Bundle(url: child)?.bundleIdentifier {
extensions[identifier] = appURL
}
}
}
}
func applicationURL(pid: Int32) -> URL? {
if let path = processExecutablePath(pid) {
if let app = enclosingApplication(path) { return app }
var url = URL(fileURLWithPath: path)
while url.path != "/" {
if url.pathExtension == "systemextension", let identifier = Bundle(url: url)?.bundleIdentifier {
indexExtensions()
if let app = extensions[identifier] { return app }
}
url.deleteLastPathComponent()
}
}
return NSRunningApplication(processIdentifier: pid)?.bundleURL
}
func icon(pid: Int32) -> NSImage? {
if let url = applicationURL(pid: pid) {
if let cached = icons[url] { return cached }
let image = NSWorkspace.shared.icon(forFile: url.path)
icons[url] = image
return image
}
return NSRunningApplication(processIdentifier: pid)?.icon
?? NSImage(systemSymbolName: "terminal", accessibilityDescription: "后台进程")
}
}
// MARK: - Network monitor (plain callbacks, no Combine/SwiftUI dependency)
final class NetworkMonitor {
var onSpeedUpdate: ((String, String) -> Void)?
var onAppsUpdate: (([AppUsage]) -> Void)?
private let iconResolver = ProcessIconResolver()
private var lastDown: UInt64 = 0
private var lastUp: UInt64 = 0
private var lastAppBytes: [Int32: (down: UInt64, up: UInt64)] = [:]
private var lastAppPollTime: TimeInterval = ProcessInfo.processInfo.systemUptime
private var appPollCounter = 0
private var isPopoverOpen = false
private var isPolling = false
func start() {
let (down, up) = currentNetworkBytes()
lastDown = down
lastUp = up
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
self?.tick()
}
}
private func tick() {
let (down, up) = currentNetworkBytes()
let downDelta = down >= lastDown ? down - lastDown : 0
let upDelta = up >= lastUp ? up - lastUp : 0
lastDown = down
lastUp = up
onSpeedUpdate?(formatRate(downDelta), formatRate(upDelta))
guard isPopoverOpen else { return }
appPollCounter += 1
if appPollCounter >= 2 {
appPollCounter = 0
refreshTopApps()
}
}
func popoverOpened() {
isPopoverOpen = true
lastAppBytes = [:]
appPollCounter = 0
refreshTopApps()
}
func popoverClosed() {
isPopoverOpen = false
}
private func refreshTopApps() {
guard !isPolling else { return }
isPolling = true
let pollTime = ProcessInfo.processInfo.systemUptime
DispatchQueue.global(qos: .utility).async { [weak self] in
let raw = runNettopSnapshot().map(parseNettop)
DispatchQueue.main.async {
guard let self = self else { return }
self.isPolling = false
guard self.isPopoverOpen, let raw = raw else { return }
self.applyNettopSnapshot(raw, at: pollTime)
}
}
}
private func applyNettopSnapshot(_ raw: [RawUsage], at pollTime: TimeInterval) {
let elapsed = pollTime - lastAppPollTime
lastAppPollTime = pollTime
// A fresh opening needs a baseline before reporting any rates.
let hasBaseline = !lastAppBytes.isEmpty && elapsed > 0.1
var deltas: [(pid: Int32, name: String, down: UInt64, up: UInt64)] = []
var newSnapshot: [Int32: (down: UInt64, up: UInt64)] = [:]
for usage in raw {
newSnapshot[usage.pid] = (usage.bytesIn, usage.bytesOut)
if hasBaseline, let prev = lastAppBytes[usage.pid] {
let downDeltaBytes = usage.bytesIn >= prev.down ? usage.bytesIn - prev.down : 0
let upDeltaBytes = usage.bytesOut >= prev.up ? usage.bytesOut - prev.up : 0
if downDeltaBytes > 0 || upDeltaBytes > 0 {
let downRate = UInt64(Double(downDeltaBytes) / elapsed)
let upRate = UInt64(Double(upDeltaBytes) / elapsed)
deltas.append((usage.pid, usage.name, downRate, upRate))
}
}
}
lastAppBytes = newSnapshot
guard hasBaseline else { return }
let top = deltas
.map { entry -> AppUsage in
let runningApp = NSRunningApplication(processIdentifier: entry.pid)
let name = runningApp?.localizedName ?? entry.name
let icon = iconResolver.icon(pid: entry.pid)
return AppUsage(pid: entry.pid, name: name, icon: icon, downRate: entry.down, upRate: entry.up)
}
onAppsUpdate?(Array(top))
}
}
// MARK: - Ranking and presentation
enum SortMetric: String {
case upload, download
func rate(_ app: AppUsage) -> UInt64 {
self == .upload ? app.upRate : app.downRate
}
}
func rankedApps(_ apps: [AppUsage], metric: SortMetric, ascending: Bool) -> [AppUsage] {
apps.sorted {
let lhs = metric.rate($0), rhs = metric.rate($1)
if lhs != rhs { return ascending ? lhs < rhs : lhs > rhs }
if $0.name != $1.name { return $0.name < $1.name }
return $0.pid < $1.pid
}
}
private let panelWidth: CGFloat = 320
private let panelHeight: CGFloat = 382
class FlippedView: NSView {
override var isFlipped: Bool { true }
}
private func label(_ text: String, _ frame: NSRect, size: CGFloat = 11,
weight: NSFont.Weight = .regular) -> NSTextField {
let field = NSTextField(labelWithString: text)
field.frame = frame
field.font = .systemFont(ofSize: size, weight: weight)
field.textColor = NSColor(calibratedWhite: 0.32, alpha: 1)
field.lineBreakMode = .byTruncatingTail
return field
}
final class AppRowView: FlippedView {
init(app: AppUsage, metric: SortMetric, maximum: UInt64, y: CGFloat) {
super.init(frame: NSRect(x: 0, y: y, width: 296, height: 28))
let fraction = maximum == 0 ? 0 : CGFloat(metric.rate(app)) / CGFloat(maximum)
let bar = NSView(frame: NSRect(x: 0, y: 2, width: 148 * fraction, height: 24))
bar.wantsLayer = true
bar.layer?.backgroundColor = NSColor(calibratedWhite: 0.94, alpha: 1).cgColor
addSubview(bar)
let icon = NSImageView(frame: NSRect(x: 6, y: 6, width: 16, height: 16))
icon.image = app.icon ?? NSImage(systemSymbolName: "network", accessibilityDescription: "进程")
icon.contentTintColor = .gray
addSubview(icon)
let name = label(app.name, NSRect(x: 29, y: 6, width: 118, height: 18))
name.toolTip = "\(app.name) · PID \(app.pid)"
addSubview(name)
for (x, text) in [(CGFloat(150), app.upText), (CGFloat(223), app.downText)] {
let value = label(text, NSRect(x: x, y: 7, width: 70, height: 16), size: 10)
value.font = .monospacedDigitSystemFont(ofSize: 10, weight: .regular)
value.alignment = .right
addSubview(value)
}
}
required init?(coder: NSCoder) { fatalError("init(coder:) not supported") }
}
enum LoginItemState { case off, enabled, needsApproval }
protocol LoginItemManaging {
var state: LoginItemState { get }
func setEnabled(_ enabled: Bool) throws
func openSettings()
}
final class LoginItemManager: LoginItemManaging {
private let legacyURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/local.netspeed.app.login.plist")
var state: LoginItemState {
if #available(macOS 13.0, *) {
switch SMAppService.mainApp.status {
case .enabled: return .enabled
case .requiresApproval: return .needsApproval
default: return .off
}
}
return FileManager.default.fileExists(atPath: legacyURL.path) ? .enabled : .off
}
func setEnabled(_ enabled: Bool) throws {
guard Bundle.main.bundleURL.pathExtension == "app" else {
throw NSError(domain: "NetSpeed", code: 1, userInfo: [NSLocalizedDescriptionKey: "请将 NetSpeed 安装到应用程序文件夹后设置开机自启。"])
}
if #available(macOS 13.0, *) {
if enabled {
if state == .off { try SMAppService.mainApp.register() }
} else if state != .off {
try SMAppService.mainApp.unregister()
}
// Retire only this app's macOS 12 login entry after successful migration.
if FileManager.default.fileExists(atPath: legacyURL.path) {
try FileManager.default.removeItem(at: legacyURL)
}
} else if enabled {
let plist: [String: Any] = ["Label": "local.netspeed.app.login",
"ProgramArguments": ["/usr/bin/open", "-a", Bundle.main.bundlePath], "RunAtLoad": true]
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
try FileManager.default.createDirectory(at: legacyURL.deletingLastPathComponent(), withIntermediateDirectories: true)
try data.write(to: legacyURL, options: .atomic)
} else if FileManager.default.fileExists(atPath: legacyURL.path) {
try FileManager.default.removeItem(at: legacyURL)
}
}
func openSettings() {
if #available(macOS 13.0, *) { SMAppService.openSystemSettingsLoginItems() }
}
}
final class PopoverViewController: NSViewController {
private let loginItems: LoginItemManaging
private let loginToggle = NSButton(checkboxWithTitle: "开机自启", target: nil, action: nil)
private let approvalButton = NSButton(title: "前往允许", target: nil, action: nil)
init(loginItems: LoginItemManaging = LoginItemManager()) {
self.loginItems = loginItems
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("init(coder:) not supported") }
private let upValue = label("-- KB/s", NSRect(x: 16, y: 42, width: 142, height: 24), size: 17)
private let downValue = label("-- KB/s", NSRect(x: 170, y: 42, width: 142, height: 24), size: 17)
private let uploadButton = NSButton()
private let downloadButton = NSButton()
private let scroll = NSScrollView(frame: NSRect(x: 12, y: 126, width: 296, height: 224))
private let list = FlippedView()
private let emptyLabel = label("正在采样,请稍候…", NSRect(x: 12, y: 172, width: 296, height: 22))
private var apps: [AppUsage] = []
private var metric = SortMetric(rawValue: UserDefaults.standard.string(forKey: "sortMetric") ?? "") ?? .download
private var ascending = UserDefaults.standard.bool(forKey: "sortAscending")
override func loadView() {
view = FlippedView(frame: NSRect(x: 0, y: 0, width: panelWidth, height: panelHeight))
view.appearance = NSAppearance(named: .aqua)
view.wantsLayer = true
view.layer?.backgroundColor = NSColor.white.cgColor
view.addSubview(label("网络速度", NSRect(x: 16, y: 11, width: 180, height: 22), size: 14))
let accent = NSView(frame: NSRect(x: 16, y: 34, width: 55, height: 1))
accent.wantsLayer = true
accent.layer?.backgroundColor = NSColor.systemOrange.cgColor
view.addSubview(accent)
view.addSubview(upValue)
view.addSubview(downValue)
view.addSubview(label("↑ 上传", NSRect(x: 16, y: 68, width: 142, height: 17), size: 10))
view.addSubview(label("↓ 下载", NSRect(x: 170, y: 68, width: 142, height: 17), size: 10))
let separator = NSBox(frame: NSRect(x: 12, y: 92, width: 296, height: 1))
separator.boxType = .separator
view.addSubview(separator)
view.addSubview(label("应用 / 进程", NSRect(x: 18, y: 103, width: 136, height: 18), size: 10))
for (button, x, action) in [(uploadButton, CGFloat(162), #selector(sortUpload)),
(downloadButton, CGFloat(235), #selector(sortDownload))] {
button.frame = NSRect(x: x, y: 98, width: 70, height: 26)
button.isBordered = false
button.font = .systemFont(ofSize: 10, weight: .regular)
button.target = self
button.action = action
button.toolTip = "点击按此速度排序,再次点击切换升序 / 降序"
view.addSubview(button)
}
scroll.drawsBackground = false
scroll.hasVerticalScroller = true
scroll.autohidesScrollers = true
scroll.documentView = list
view.addSubview(scroll)
emptyLabel.alignment = .center
emptyLabel.textColor = .secondaryLabelColor
view.addSubview(emptyLabel)
let footer = NSView(frame: NSRect(x: 0, y: 354, width: panelWidth, height: 28))
footer.wantsLayer = true
footer.layer?.backgroundColor = NSColor(calibratedWhite: 0.965, alpha: 1).cgColor
view.addSubview(footer)
loginToggle.frame = NSRect(x: 14, y: 357, width: 100, height: 22)
loginToggle.font = .systemFont(ofSize: 10, weight: .regular)
loginToggle.controlSize = .small
loginToggle.target = self
loginToggle.action = #selector(toggleLoginItem)
loginToggle.toolTip = "登录当前 macOS 账户后自动启动 NetSpeed"
view.addSubview(loginToggle)
approvalButton.frame = NSRect(x: 122, y: 357, width: 76, height: 22)
approvalButton.font = .systemFont(ofSize: 10, weight: .regular)
approvalButton.isBordered = false
approvalButton.contentTintColor = .systemOrange
approvalButton.target = self
approvalButton.action = #selector(openLoginSettings)
view.addSubview(approvalButton)
refreshLoginState()
let quit = NSButton(title: "退出", target: NSApp, action: #selector(NSApplication.terminate(_:)))
quit.frame = NSRect(x: 274, y: 356, width: 36, height: 24)
quit.font = .systemFont(ofSize: 10, weight: .regular)
quit.isBordered = false
view.addSubview(quit)
updateSortButtons()
}
private func refreshLoginState() {
let state = loginItems.state
loginToggle.state = state == .off ? .off : .on
approvalButton.isHidden = state != .needsApproval
}
@objc private func toggleLoginItem() {
do {
try loginItems.setEnabled(loginToggle.state == .on)
refreshLoginState()
} catch {
refreshLoginState()
let alert = NSAlert()
alert.messageText = "无法更新开机自启"
alert.informativeText = error.localizedDescription
if let window = view.window { alert.beginSheetModal(for: window) }
else { alert.runModal() }
}
}
@objc private func openLoginSettings() { loginItems.openSettings() }
private func updateSortButtons() {
let indicator = ascending ? " ▴" : " ▾"
uploadButton.title = "上传" + (metric == .upload ? indicator : "")
downloadButton.title = "下载" + (metric == .download ? indicator : "")
uploadButton.contentTintColor = metric == .upload ? .systemOrange : .darkGray
downloadButton.contentTintColor = metric == .download ? .systemOrange : .darkGray
}
private func sort(by selected: SortMetric) {
ascending = metric == selected ? !ascending : false
metric = selected
UserDefaults.standard.set(metric.rawValue, forKey: "sortMetric")
UserDefaults.standard.set(ascending, forKey: "sortAscending")
updateSortButtons()
renderApps()
scroll.contentView.scroll(to: .zero)
}
@objc private func sortUpload() { sort(by: .upload) }
@objc private func sortDownload() { sort(by: .download) }
func updateSpeed(down: String, up: String) {
upValue.stringValue = up.trimmingCharacters(in: .whitespaces)
downValue.stringValue = down.trimmingCharacters(in: .whitespaces)
}
func beginSampling() {
_ = view
refreshLoginState()
apps = []
renderApps()
emptyLabel.stringValue = "正在采样,请稍候…"
}
func updateApps(_ apps: [AppUsage]) {
_ = view
self.apps = apps
renderApps()
}
private func renderApps() {
list.subviews.forEach { $0.removeFromSuperview() }
let sorted = rankedApps(apps, metric: metric, ascending: ascending)
let maximum = apps.map { metric.rate($0) }.max() ?? 0
list.frame = NSRect(x: 0, y: 0, width: 296, height: max(224, CGFloat(sorted.count) * 28))
for (index, app) in sorted.enumerated() {
list.addSubview(AppRowView(app: app, metric: metric, maximum: maximum, y: CGFloat(index) * 28))
}
emptyLabel.stringValue = "暂无活跃网络流量"
emptyLabel.isHidden = !apps.isEmpty
}
}
// Stable menu bar layout: fixed font, fixed columns, at most three significant digits.
struct StatusRate {
let number: String
let unit: String
}
func statusRate(_ rate: String) -> StatusRate {
let parts = rate.split(whereSeparator: { $0.isWhitespace })
let units = ["K", "M", "G", "T", "P", "E"]
guard parts.count == 2, var value = Double(parts[0]), value.isFinite, value >= 0,
let initial = units.firstIndex(of: String(parts[1].prefix(1))) else {
return StatusRate(number: "--", unit: "K/s")
}
var index = initial
// Promote before integer rounding would produce four digits (1000).
while value >= 999.5 && index < units.count - 1 {
value /= 1024
index += 1
}
let decimals = (value * 10).rounded() < 1000 ? 1 : 0
let number = String(format: decimals == 1 ? "%.1f" : "%.0f", locale: Locale(identifier: "en_US_POSIX"), value)
return StatusRate(number: number, unit: units[index] + "/s")
}
func compactStatusRate(_ rate: String) -> String {
let value = statusRate(rate)
return value.number + " " + value.unit
}
let statusFont = NSFont.monospacedDigitSystemFont(ofSize: 8, weight: .regular)
// Compute the fixed columns once from the widest supported labels, never from live rates.
private func statusTextWidth(_ text: String) -> CGFloat {
(text as NSString).size(withAttributes: [.font: statusFont]).width
}
let statusNumberLeft: CGFloat = 1 + max(statusTextWidth("↑"), statusTextWidth("↓")) + 1
let statusNumberRight: CGFloat = statusNumberLeft + max(statusTextWidth("99.9"), statusTextWidth("999"), statusTextWidth("--"))
let statusUnitLeft: CGFloat = statusNumberRight + 1
let statusImageWidth: CGFloat = ceil(statusUnitLeft + ["K/s", "M/s", "G/s", "T/s", "P/s", "E/s"].map(statusTextWidth).max()! + 1)
let statusItemWidth: CGFloat = statusImageWidth + 2
func statusImage(down: String, up: String) -> NSImage {
let rates = [statusRate(up), statusRate(down)]
let attributes: [NSAttributedString.Key: Any] = [
.font: statusFont,
.foregroundColor: NSColor.white
]
let image = NSImage(size: NSSize(width: statusImageWidth, height: 22), flipped: false) { _ in
for (index, rate) in rates.enumerated() {
let numberSize = (rate.number as NSString).size(withAttributes: attributes)
let y = CGFloat(1 - index) * 11 + max(0, (11 - numberSize.height) / 2)
((index == 0 ? "↑" : "↓") as NSString).draw(at: NSPoint(x: 1, y: y), withAttributes: attributes)
(rate.number as NSString).draw(at: NSPoint(x: statusNumberRight - numberSize.width, y: y), withAttributes: attributes)
(rate.unit as NSString).draw(at: NSPoint(x: statusUnitLeft, y: y), withAttributes: attributes)
}
return true
}
image.isTemplate = false
return image
}
final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
private var statusItem: NSStatusItem!
private var popover: NSPopover!
private let monitor = NetworkMonitor()
private let panel = PopoverViewController()
func applicationDidFinishLaunching(_ notification: Notification) {
statusItem = NSStatusBar.system.statusItem(withLength: statusItemWidth)
updateStatus(down: "-- KB/s", up: "-- KB/s")
statusItem.button?.action = #selector(togglePopover)
statusItem.button?.target = self
popover = NSPopover()
popover.behavior = .transient
popover.contentSize = NSSize(width: panelWidth, height: panelHeight)
popover.contentViewController = panel
popover.delegate = self
monitor.onSpeedUpdate = { [weak self] down, up in
self?.updateStatus(down: down, up: up)
self?.statusItem.button?.setAccessibilityLabel("上传 \(up),下载 \(down)")
self?.panel.updateSpeed(down: down, up: up)
}
monitor.onAppsUpdate = { [weak self] in self?.panel.updateApps($0) }
monitor.start()
}
private func updateStatus(down: String, up: String) {
let image = statusImage(down: down, up: up)
statusItem.button?.image = image
statusItem.button?.imageScaling = .scaleNone
}
@objc private func togglePopover() {
guard let button = statusItem.button else { return }
if popover.isShown { popover.performClose(nil) }
else {
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
popover.contentViewController?.view.window?.makeKey()
}
}
func popoverWillShow(_ notification: Notification) {
panel.beginSampling()
monitor.popoverOpened()
}
func popoverDidClose(_ notification: Notification) { monitor.popoverClosed() }
}
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.setActivationPolicy(.accessory)
app.run()