Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: CI

on:
pull_request:
branches: [main]
push:
branches: [main]

permissions:
contents: read
pull-requests: write

env:
SCHEME: NetMeter
PROJECT: NetMeter.xcodeproj

jobs:
build-and-test:
runs-on: macos-15
steps:
- uses: actions/checkout@v4

- name: Build (arm64)
id: build
continue-on-error: true
run: |
set -o pipefail
START=$SECONDS
xcodebuild clean build \
-project "$PROJECT" \
-scheme "$SCHEME" \
-configuration Debug \
ARCHS=arm64 \
ONLY_ACTIVE_ARCH=NO \
CODE_SIGNING_ALLOWED=NO \
2>&1
echo "duration=$((SECONDS - START))s" >> "$GITHUB_OUTPUT"

- name: Test
id: test
continue-on-error: true
run: |
set -o pipefail
START=$SECONDS
xcodebuild test \
-project "$PROJECT" \
-scheme "$SCHEME" \
-destination 'platform=macOS' \
2>&1
echo "duration=$((SECONDS - START))s" >> "$GITHUB_OUTPUT"

- name: Post result to PR
if: github.event_name == 'pull_request'
uses: marocchino/sticky-pull-request-comment@v2
with:
header: ci-result
message: |
## CI 结果

| 步骤 | 状态 | 耗时 |
|------|------|------|
| 🔨 Build (arm64) | ${{ steps.build.outcome == 'success' && '✅ 通过' || '❌ 失败' }} | ${{ steps.build.outputs.duration }} |
| 🧪 Tests | ${{ steps.test.outcome == 'success' && '✅ 通过' || '❌ 失败' }} | ${{ steps.test.outputs.duration }} |

> `${{ github.sha }}` · [查看完整日志](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})

- name: Fail if build or tests failed
if: steps.build.outcome == 'failure' || steps.test.outcome == 'failure'
run: exit 1
51 changes: 44 additions & 7 deletions NetMeter/MenuBarStatusController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ final class MenuBarStatusController: NSObject {
private var aboutWindow: NSWindow?
/// 定时拉取速率刷新菜单栏,避免 withObservationTracking 高频闭环占满 CPU
private var menuBarRefreshTimer: Timer?
private var screenObservers: [NSObjectProtocol] = []

private override init() {
super.init()
Expand All @@ -55,6 +56,8 @@ final class MenuBarStatusController: NSObject {
deinit {
shrinkWidthWorkItem?.cancel()
menuBarRefreshTimer?.invalidate()
let ws = NSWorkspace.shared.notificationCenter
screenObservers.forEach { ws.removeObserver($0) }
}

func install(monitor: NetworkSpeedMonitor) {
Expand Down Expand Up @@ -139,6 +142,7 @@ final class MenuBarStatusController: NSObject {
item.menu = menu
updateLabels()
startMenuBarRefreshTimer(for: monitor)
setupScreenObservers()
}

private func makeArrowLabel(isUpload: Bool) -> NSTextField {
Expand Down Expand Up @@ -176,10 +180,43 @@ final class MenuBarStatusController: NSObject {
}
}
t.tolerance = interval * 0.5
RunLoop.main.add(t, forMode: .default)
RunLoop.main.add(t, forMode: .common)
menuBarRefreshTimer = t
}

private func setupScreenObservers() {
let ws = NSWorkspace.shared.notificationCenter
screenObservers.forEach { ws.removeObserver($0) }
screenObservers.removeAll()

let pause: @Sendable (Notification) -> Void = { [weak self] _ in
DispatchQueue.main.async {
MainActor.assumeIsolated {
self?.menuBarRefreshTimer?.invalidate()
self?.menuBarRefreshTimer = nil
}
}
}
let resume: @Sendable (Notification) -> Void = { [weak self] _ in
DispatchQueue.main.async {
MainActor.assumeIsolated {
guard let self, let monitor = self.monitor else { return }
self.startMenuBarRefreshTimer(for: monitor)
}
}
}
screenObservers = [
ws.addObserver(forName: NSWorkspace.willSleepNotification,
object: nil, queue: nil, using: pause),
ws.addObserver(forName: NSWorkspace.screensDidSleepNotification,
object: nil, queue: nil, using: pause),
ws.addObserver(forName: NSWorkspace.didWakeNotification,
object: nil, queue: nil, using: resume),
ws.addObserver(forName: NSWorkspace.screensDidWakeNotification,
object: nil, queue: nil, using: resume),
]
}

private func makeSpeedLabel() -> NSTextField {
let f = NSTextField(labelWithString: "—")
f.font = MenuBarSpeedLines.menuBarMonospaceFont
Expand Down Expand Up @@ -280,13 +317,13 @@ final class MenuBarStatusController: NSObject {
private func updateLabels() {
guard let monitor else { return }
let lines = MenuBarSpeedLines.make(uploadBps: monitor.uploadBps, downloadBps: monitor.downloadBps)
if labelUp?.stringValue != lines.upload {
labelUp?.stringValue = lines.upload
}
if labelDown?.stringValue != lines.download {
labelDown?.stringValue = lines.download
let uploadChanged = labelUp?.stringValue != lines.upload
let downloadChanged = labelDown?.stringValue != lines.download
if uploadChanged { labelUp?.stringValue = lines.upload }
if downloadChanged { labelDown?.stringValue = lines.download }
if uploadChanged || downloadChanged {
reconcileSpeedLabelWidths(upload: lines.upload, download: lines.download)
}
reconcileSpeedLabelWidths(upload: lines.upload, download: lines.download)
}

/// 目标宽小于已应用宽时防抖收窄;变宽立即跟上
Expand Down
4 changes: 2 additions & 2 deletions NetMeter/NetMeterApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ struct NetMeterApp: App {

/// 菜单、关于页、状态栏 toolTip 用的展示名(勿用 `AppBundleDisplay` 作类型名,易与编译器生成符号冲突)
enum NetMeterDisplayName {
static var resolved: String {
static let resolved: String = {
let b = Bundle.main
if let s = b.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String, !s.isEmpty { return s }
if let s = b.object(forInfoDictionaryKey: "CFBundleName") as? String, !s.isEmpty { return s }
return ProcessInfo.processInfo.processName
}
}()
}
10 changes: 6 additions & 4 deletions NetMeter/NetworkSpeedMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,9 @@ struct SpeedDisplayState: Equatable, Sendable {
private enum SamplingPolicy {
static let minInterval: Double = 0.2
static let maxInterval: Double = 60
/// 连续无流量超过此次数后降低采样频率
/// 连续无流量超过此次数后进入退避;每经过一个 threshold 周期指数翻倍,直至 maxInterval
static let idleStreakThreshold = 3
static let idleBackoffMultiplier: Double = 2
/// 空闲降频上限(秒)
static let idleBackoffCap: Double = 8
}

// MARK: - 门面
Expand Down Expand Up @@ -230,7 +228,11 @@ final class NetworkSpeedMonitor: ObservableObject, @unchecked Sendable {

var sleepSec = interval
if idleStreak >= SamplingPolicy.idleStreakThreshold {
sleepSec = min(SamplingPolicy.idleBackoffCap, interval * SamplingPolicy.idleBackoffMultiplier)
let steps = idleStreak / SamplingPolicy.idleStreakThreshold
sleepSec = min(
SamplingPolicy.maxInterval,
interval * pow(SamplingPolicy.idleBackoffMultiplier, Double(steps))
)
}
let sleepClamped = max(SamplingPolicy.minInterval, min(sleepSec, SamplingPolicy.maxInterval))
let ns = UInt64(sleepClamped * 1_000_000_000)
Expand Down
2 changes: 1 addition & 1 deletion docs/perf-energy.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

| 优化 | 说明 |
|------|------|
| RunLoop `.default` | 菜单栏 Timer `.common` 改为 `.default`,允许 App Nap coalescing |
| RunLoop `.common` | 菜单栏 Timer 使用 `.common` 模式,确保菜单打开期间速率显示持续刷新 |
| Timer tolerance 50% | 允许系统将唤醒合并到同一 CPU 唤醒周期 |
| 休眠/灭屏停止采样 | `NSWorkspace` 通知驱动,屏关后采样 Task 取消,唤醒后自动重启 |

Expand Down
Loading