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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ GitHub Actions now builds the Swift package and app bundle automatically on push
## Notes on CPU temperature
macOS does not expose CPU temperature through a stable public API for normal apps. This app therefore:
- tries to read temperature from installed helper tools such as `osx-cpu-temp` or `istats`
- falls back to `--` plus thermal state if no helper is available
- on Apple Silicon: reads HID sensors first, falls back to helper tools
- shows an actionable hint (e.g. install hint on Intel, permission check on Apple Silicon) when temperature is unavailable — no silent `--`
- does **not** require sudo or private entitlements

## Known limitations
Expand All @@ -71,8 +72,7 @@ macOS does not expose CPU temperature through a stable public API for normal app
- The app is still being polished for wider public release

## Roadmap ideas
- Sparklines / history
- Per-process actions
- Per-process actions (e.g. kill, priority adjustment)
- Better sensor integrations
- Further performance tuning

Expand Down
6 changes: 3 additions & 3 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ GitHub Actions 已经配置好,会在 push、pull request 和版本 tag 时自
## 关于 CPU 温度
macOS 没有为普通应用提供稳定的公开 CPU 温度 API,所以这个项目会:
- 尝试读取已安装的辅助工具,例如 `osx-cpu-temp` 或 `istats`
- 如果没有可用辅助工具,就回退显示为 `--` 和热状态
- Apple Silicon 机型:优先读取 HID 传感器,辅助工具作为后备
- 温度不可用时显示可操作提示(Intel 提示安装工具,Apple Silicon 提示检查权限),不会静默显示 `--`
- **不需要** sudo,也不依赖私有 entitlement

## 已知限制
Expand All @@ -70,8 +71,7 @@ macOS 没有为普通应用提供稳定的公开 CPU 温度 API,所以这个
- 这个应用还在继续打磨,离更成熟的公开版本还有一些工作

## Roadmap 想法
- 趋势图 / 历史曲线
- 针对进程的操作按钮
- 针对进程的操作按钮(如结束进程、优先级调整)
- 更好的传感器集成
- 进一步的性能优化

Expand Down
55 changes: 40 additions & 15 deletions Sources/ProcessBarMonitor/MonitorViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,6 @@ private struct SettingsStore {
@MainActor
final class MonitorViewModel: ObservableObject {
let launchAtLogin = LaunchAtLoginManager()
private let summaryRefreshInterval: UInt64 = 2_000_000_000
private let processRefreshInterval: TimeInterval = 10

@Published private(set) var summary = SystemSummary.empty
@Published private(set) var allProcesses: [ProcessStat] = []
Expand All @@ -41,25 +39,34 @@ final class MonitorViewModel: ObservableObject {
@Published var processLimit: Int {
didSet {
settings.set(processLimit, forKey: Keys.processLimit)
// Immediately reflect the new row count in the visible process list.
recomputeVisibleProcesses()
}
}
@Published var temperatureMode: TemperatureMode {
didSet {
settings.set(temperatureMode.rawValue, forKey: Keys.temperatureMode)
// Immediate refresh ensures the new temperature mode is reflected
// without waiting for the next scheduled refresh cycle.
Task { await refresh(forceProcesses: true) }
}
}
@Published var menuBarDisplayMode: MenuBarDisplayMode {
didSet {
settings.set(menuBarDisplayMode.rawValue, forKey: Keys.menuBarDisplayMode)
// Refresh so the menu bar title format update is immediate.
Task { await refresh(forceProcesses: false) }
}
}
@Published var refreshRatePreset: RefreshRatePreset {
didSet {
settings.set(refreshRatePreset.rawValue, forKey: Keys.refreshRatePreset)
applyRefreshRatePreset()
if let existingTask = refreshTask {
existingTask.cancel()
refreshTask = createRefreshTask()
}
}
}

private var currentSummaryRefreshInterval: UInt64 = 2_000_000_000
private var currentProcessRefreshInterval: TimeInterval = 10

private let metricsProvider = SystemMetricsProvider()
private let processProvider = ProcessSnapshotProvider.shared
Expand All @@ -74,6 +81,7 @@ final class MonitorViewModel: ObservableObject {
static let processLimit = "processLimit"
static let temperatureMode = "temperatureMode"
static let menuBarDisplayMode = "menuBarDisplayMode"
static let refreshRatePreset = "refreshRatePreset"
}

init(defaults: UserDefaults = .standard) {
Expand All @@ -96,6 +104,13 @@ final class MonitorViewModel: ObservableObject {
} else {
menuBarDisplayMode = .compact
}

if let rawRefreshRatePreset = settings.string(forKey: Keys.refreshRatePreset),
let parsedRefreshRatePreset = RefreshRatePreset(savedValue: rawRefreshRatePreset) {
refreshRatePreset = parsedRefreshRatePreset
} else {
refreshRatePreset = .balanced
}
}

var menuBarTitle: String {
Expand All @@ -119,6 +134,22 @@ final class MonitorViewModel: ObservableObject {
return String(format: "%.1fG", gb)
}

private func applyRefreshRatePreset() {
currentSummaryRefreshInterval = refreshRatePreset.summaryInterval
currentProcessRefreshInterval = refreshRatePreset.processInterval
}

private func createRefreshTask() -> Task<Void, Never> {
Task { [weak self] in
await self?.refresh(forceProcesses: true)
while !Task.isCancelled {
guard let self else { return }
try? await Task.sleep(nanoseconds: self.currentSummaryRefreshInterval)
await self.refresh()
}
}
}

func start() {
guard refreshTask == nil else { return }

Expand All @@ -131,15 +162,9 @@ final class MonitorViewModel: ObservableObject {
}
}
launchAtLogin.refreshState()
applyRefreshRatePreset()

refreshTask = Task { [weak self] in
await self?.refresh(forceProcesses: true)
while !Task.isCancelled {
guard let self else { return }
try? await Task.sleep(nanoseconds: self.summaryRefreshInterval)
await self.refresh()
}
}
refreshTask = createRefreshTask()
}

func stop() {
Expand All @@ -159,7 +184,7 @@ final class MonitorViewModel: ObservableObject {
isRefreshing = true
defer { isRefreshing = false }

let processIntervalElapsed = Date().timeIntervalSince(lastProcessRefresh) >= processRefreshInterval
let processIntervalElapsed = Date().timeIntervalSince(lastProcessRefresh) >= currentProcessRefreshInterval
let shouldRefreshProcesses = forceProcesses
|| allProcesses.isEmpty
|| (isMenuExpanded && processIntervalElapsed)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,7 @@
"status.diagnostics_copy_failed" = "Failed to copy diagnostics.";
"settings.section.display" = "Display";
"settings.section.startup" = "Startup";
"picker.refresh_rate" = "Refresh Rate";
"refresh_rate.power_saving" = "Power Saving";
"refresh_rate.balanced" = "Balanced";
"refresh_rate.real_time" = "Real-time";
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,7 @@
"status.diagnostics_copy_failed" = "复制诊断信息失败。";
"settings.section.display" = "显示";
"settings.section.startup" = "启动";
"picker.refresh_rate" = "刷新频率";
"refresh_rate.power_saving" = "省电";
"refresh_rate.balanced" = "平衡";
"refresh_rate.real_time" = "实时";
7 changes: 6 additions & 1 deletion Sources/ProcessBarMonitor/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ struct SettingsView: View {
Text("12").tag(12)
Text("20").tag(20)
}
// Side-effect (recomputeVisibleProcesses) is in MonitorViewModel.processLimit.didSet

Picker(L10n.string("picker.refresh_rate"), selection: $viewModel.refreshRatePreset) {
ForEach(RefreshRatePreset.allCases) { preset in
Text(preset.title).tag(preset)
}
}
} header: {
Text(L10n.string("settings.section.display"))
}
Expand Down
49 changes: 49 additions & 0 deletions Sources/ProcessBarMonitor/SystemModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,55 @@ enum TemperatureMode: String, CaseIterable, Identifiable {
}
}

enum RefreshRatePreset: String, CaseIterable, Identifiable {
case powerSaving
case balanced
case realTime

var id: String { rawValue }

var title: String {
switch self {
case .powerSaving:
return L10n.string("refresh_rate.power_saving")
case .balanced:
return L10n.string("refresh_rate.balanced")
case .realTime:
return L10n.string("refresh_rate.real_time")
}
}

var summaryInterval: UInt64 {
switch self {
case .powerSaving: return 10_000_000_000
case .balanced: return 2_000_000_000
case .realTime: return 500_000_000
}
}

var processInterval: TimeInterval {
switch self {
case .powerSaving: return 30
case .balanced: return 10
case .realTime: return 5
}
}

init?(savedValue: String) {
if let preset = RefreshRatePreset(rawValue: savedValue) {
self = preset
return
}

switch savedValue {
case "Power Saving": self = .powerSaving
case "Balanced": self = .balanced
case "Real-time": self = .realTime
default: return nil
}
}
}

enum MenuBarDisplayMode: String, CaseIterable, Identifiable {
case compact
case labeled
Expand Down
49 changes: 49 additions & 0 deletions Tests/ProcessBarMonitorTests/MonitorViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -141,4 +141,53 @@ final class MonitorViewModelTests: XCTestCase {
let vm = MonitorViewModel(defaults: defaults)
XCTAssertEqual(vm.processLimit, 5, "invalid processLimit should fall back to 5")
}

// MARK: - Refresh rate preset defaults and migration

@MainActor
func testDefault_refreshRatePreset_isBalanced() async {
let defaults = UserDefaults(suiteName: UUID().uuidString)!
let vm = MonitorViewModel(defaults: defaults)
XCTAssertEqual(vm.refreshRatePreset, .balanced)
}

@MainActor
func testMigration_modernPowerSavingString() async {
let defaults = UserDefaults(suiteName: UUID().uuidString)!
defaults.set("powerSaving", forKey: "refreshRatePreset")
let vm = MonitorViewModel(defaults: defaults)
XCTAssertEqual(vm.refreshRatePreset, .powerSaving)
}

@MainActor
func testMigration_modernBalancedString() async {
let defaults = UserDefaults(suiteName: UUID().uuidString)!
defaults.set("balanced", forKey: "refreshRatePreset")
let vm = MonitorViewModel(defaults: defaults)
XCTAssertEqual(vm.refreshRatePreset, .balanced)
}

@MainActor
func testMigration_modernRealTimeString() async {
let defaults = UserDefaults(suiteName: UUID().uuidString)!
defaults.set("realTime", forKey: "refreshRatePreset")
let vm = MonitorViewModel(defaults: defaults)
XCTAssertEqual(vm.refreshRatePreset, .realTime)
}

@MainActor
func testMigration_legacyPowerSavingString() async {
let defaults = UserDefaults(suiteName: UUID().uuidString)!
defaults.set("Power Saving", forKey: "refreshRatePreset")
let vm = MonitorViewModel(defaults: defaults)
XCTAssertEqual(vm.refreshRatePreset, .powerSaving)
}

@MainActor
func testMigration_invalidRefreshRatePresetFallsBack() async {
let defaults = UserDefaults(suiteName: UUID().uuidString)!
defaults.set("foobar", forKey: "refreshRatePreset")
let vm = MonitorViewModel(defaults: defaults)
XCTAssertEqual(vm.refreshRatePreset, .balanced, "invalid value should fall back to .balanced")
}
}