Skip to content
Closed
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
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,13 @@ ProcessBarMonitor gives you a compact Activity Monitor-style summary without kee
## Features
- Menu bar utility with live summary in the menu bar title
- Overall CPU usage
- Memory used / total memory
- Memory pressure (system-wide active + inactive + wired + compressor)
- Thermal state (Nominal / Fair / Serious / Critical)
- Top apps by CPU
- Top apps by memory
- Manual refresh + automatic refresh
- Best-effort CPU temperature support
- Sparkline trends for CPU, memory, and temperature
- Best-effort CPU temperature support with actionable hints when unavailable
- Search box for filtering by app name / path / PID / bundle id
- Adjustable process row count
- Persisted display preferences for menu bar mode, temperature mode, and row count
Expand Down Expand Up @@ -62,7 +63,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 +73,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
12 changes: 7 additions & 5 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,16 @@ ProcessBarMonitor 想解决的是:你只想快速看一眼系统负载和当
## 功能
- 菜单栏常驻工具,标题里显示实时摘要
- 整体 CPU 使用率
- 已用内存 / 总内存
- 系统内存压力(active + inactive + wired + compressor)
- 热状态(Nominal / Fair / Serious / Critical)
- CPU 占用最高应用列表
- 内存占用最高应用列表
- 手动刷新 + 自动刷新
- 尽力而为的 CPU 温度支持
- 趋势图(Sparkline):CPU、内存、温度历史曲线
- 尽力而为的 CPU 温度支持,温度不可用时显示可操作提示
- 可按应用名 / 路径 / PID / bundle id 搜索
- 可调节进程列表显示行数
- 持久化显示偏好(菜单栏模式、温度模式、行数)
- 面板内置 Quit 按钮

## 安装
Expand All @@ -61,7 +63,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 +73,7 @@ macOS 没有为普通应用提供稳定的公开 CPU 温度 API,所以这个
- 这个应用还在继续打磨,离更成熟的公开版本还有一些工作

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

Expand Down
2 changes: 1 addition & 1 deletion Sources/ProcessBarMonitor/Localization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ enum L10n {
String(format: string(key), locale: Locale.current, arguments: arguments)
}

private static func localizationCandidates(for language: String) -> [String] {
static func localizationCandidates(for language: String) -> [String] {
let parts = language.split(separator: "-").map(String.init)
guard !parts.isEmpty else { return [language] }

Expand Down
8 changes: 4 additions & 4 deletions Sources/ProcessBarMonitor/MonitorViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,16 +100,16 @@ final class MonitorViewModel: ObservableObject {

var menuBarTitle: String {
let cpu = String(format: "%.0f%%", summary.cpuPercent)
let memoryUsed = shortMemoryString(bytes: summary.systemMemoryUsedBytes)
let mem = String(format: "%.0f%%", summary.memoryPressurePercent)
let temp = summary.cpuTemperatureC.map { String(format: "%.0f°", $0) } ?? "--°"

switch menuBarDisplayMode {
case .compact:
return L10n.format("menu_bar_title.compact", cpu, memoryUsed, temp)
return L10n.format("menu_bar_title.compact", cpu, mem, temp)
case .labeled:
return L10n.format("menu_bar_title.labeled", cpu, memoryUsed, temp)
return L10n.format("menu_bar_title.labeled", cpu, mem, temp)
case .temperatureFirst:
return L10n.format("menu_bar_title.temperature_first", temp, cpu, memoryUsed)
return L10n.format("menu_bar_title.temperature_first", temp, cpu, mem)
}
}

Expand Down
10 changes: 5 additions & 5 deletions Sources/ProcessBarMonitor/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
"summary.cpu" = "CPU";
"summary.ram" = "RAM (System-wide)";
"summary.ram" = "Memory pressure";
"summary.temp" = "Temp";
"summary.thermal" = "Thermal";
"metric.used_percent" = "%.0f%% used";
"health.line" = "CPU %@ · RAM %@ · Temp %@";
"metric.memory_pressure" = "%.0f%%";
"health.line" = "CPU %@ · Mem %@ · Temp %@";
"trend.cpu" = "CPU Trend";
"trend.ram" = "RAM Trend";
"trend.ram" = "Memory pressure";
"trend.temp" = "Temp Trend";
"section.filter_display" = "Filter & Display";
"picker.menu_bar" = "Menu Bar";
Expand Down Expand Up @@ -42,7 +42,7 @@
"menu_display.labeled" = "Labeled";
"menu_display.temperature_first" = "Temperature First";
"menu_bar_title.compact" = "%@ %@ %@";
"menu_bar_title.labeled" = "CPU %@ RAM %@ %@";
"menu_bar_title.labeled" = "CPU %@ Mem %@ %@";
"menu_bar_title.temperature_first" = "%@ %@ %@";
"temp_mode.hottest_cpu" = "Hottest CPU";
"temp_mode.average_cpu" = "Average CPU";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
"summary.cpu" = "CPU";
"summary.ram" = "内存 (系统级)";
"summary.ram" = "内存压力";
"summary.temp" = "温度";
"summary.thermal" = "热状态";
"metric.used_percent" = "已用 %.0f%%";
"health.line" = "CPU %@ · 内存 %@ · 温度 %@";
"metric.memory_pressure" = "%.0f%%";
"health.line" = "CPU %@ · %@%% · 温度 %@";
"trend.cpu" = "CPU 趋势";
"trend.ram" = "内存趋势";
"trend.ram" = "内存压力";
"trend.temp" = "温度趋势";
"section.filter_display" = "筛选与显示";
"picker.menu_bar" = "菜单栏";
Expand Down
7 changes: 6 additions & 1 deletion Sources/ProcessBarMonitor/SystemMetricsProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -185,13 +185,18 @@ actor SystemMetricsProvider {
return extractTemperature(from: raw)
}

private func extractTemperature(from raw: String) -> Double? {
private nonisolated func extractTemperature(from raw: String) -> Double? {
let matches = raw.matches(of: /-?\d+(?:\.\d+)?/)
guard let first = matches.first,
let value = Double(first.output), value > 1, value < 120 else { return nil }
return value
}

/// Test-only: exposes extractTemperature to the test target without breaking actor isolation.
nonisolated func extractTemperatureTest(from raw: String) -> Double? {
extractTemperature(from: raw)
}

private func architectureAndTemperatureNote(temperatureAvailable: Bool, mode: TemperatureMode) -> String {
if temperatureAvailable {
return L10n.format("note.temperature.available", mode.title)
Expand Down
14 changes: 12 additions & 2 deletions Sources/ProcessBarMonitor/Views.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
struct SummaryCardView: View {
let title: String
let value: String
var subtitle: String? = nil
var accent: Color = .primary

var body: some View {
Expand All @@ -23,6 +24,11 @@ struct SummaryCardView: View {
.foregroundStyle(accent)
.lineLimit(2)
.minimumScaleFactor(0.7)
if let subtitle {
Text(subtitle)
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
Expand Down Expand Up @@ -138,13 +144,17 @@ struct MenuBarContentView: View {
}

private var memorySummary: String {
String(format: "%.0f%%", viewModel.summary.memoryPressurePercent)
}

private var memoryBytesDetail: String {
let used = ByteCountFormatter.string(fromByteCount: Int64(viewModel.summary.systemMemoryUsedBytes), countStyle: .memory)
let total = ByteCountFormatter.string(fromByteCount: Int64(viewModel.summary.memoryTotalBytes), countStyle: .memory)
return "\(used) / \(total)"
}

private var memoryCompact: String {
L10n.format("metric.used_percent", viewModel.summary.memoryPressurePercent)
L10n.format("metric.memory_pressure", viewModel.summary.memoryPressurePercent)
}

private var currentTemperatureColor: Color {
Expand All @@ -164,7 +174,7 @@ struct MenuBarContentView: View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
SummaryCardView(title: L10n.string("summary.cpu"), value: String(format: "%.1f %%", viewModel.summary.cpuPercent), accent: .primary)
SummaryCardView(title: L10n.string("summary.ram"), value: memorySummary, accent: .blue)
SummaryCardView(title: L10n.string("summary.ram"), value: memorySummary, subtitle: memoryBytesDetail, accent: .blue)
SummaryCardView(title: L10n.string("summary.temp"), value: viewModel.summary.cpuTemperatureC.map { String(format: "%.1f °C", $0) } ?? "--", accent: currentTemperatureColor)
SummaryCardView(title: L10n.string("summary.thermal"), value: viewModel.thermalText(viewModel.summary.thermalState), accent: .pink)
}
Expand Down
149 changes: 149 additions & 0 deletions Tests/ProcessBarMonitorTests/DiagnosticsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import XCTest
@testable import ProcessBarMonitor

/// Regression tests for ProcessSnapshotDiagnostics state machine (issue #30).
/// Verifies that each mutating method produces the expected side-effects
/// without interfering with other fields.
final class DiagnosticsTests: XCTestCase {

private var diagnostics: ProcessSnapshotDiagnostics!
private let fixedDate = Date(timeIntervalSince1970: 1_234_567_890)

override func setUp() {
super.setUp()
diagnostics = ProcessSnapshotDiagnostics()
}

override func tearDown() {
diagnostics = nil
super.tearDown()
}

// MARK: - Initial state

func testInitialState_allCountersZero() {
XCTAssertEqual(diagnostics.attemptCount, 0)
XCTAssertEqual(diagnostics.successCount, 0)
XCTAssertEqual(diagnostics.failureCount, 0)
}

func testInitialState_allDatesNil() {
XCTAssertNil(diagnostics.lastAttemptAt)
XCTAssertNil(diagnostics.lastSuccessAt)
XCTAssertNil(diagnostics.lastFailureAt)
}

func testInitialState_noErrorMessage() {
XCTAssertNil(diagnostics.lastFailureMessage)
XCTAssertNil(diagnostics.lastFailureDetails)
}

func testInitialState_zeroProcessCounts() {
XCTAssertEqual(diagnostics.lastSnapshotProcessCount, 0)
XCTAssertEqual(diagnostics.lastTopCPUCount, 0)
XCTAssertEqual(diagnostics.lastTopMemoryCount, 0)
}

// MARK: - markAttempt

func testMarkAttempt_incrementsAttemptCount() {
diagnostics.markAttempt(at: fixedDate)
XCTAssertEqual(diagnostics.attemptCount, 1)
XCTAssertEqual(diagnostics.lastAttemptAt, fixedDate)
}

func testMarkAttempt_multipleCallsAccumulate() {
let d1 = Date(timeIntervalSince1970: 1)
let d2 = Date(timeIntervalSince1970: 2)
let d3 = Date(timeIntervalSince1970: 3)
diagnostics.markAttempt(at: d1)
diagnostics.markAttempt(at: d2)
diagnostics.markAttempt(at: d3)
XCTAssertEqual(diagnostics.attemptCount, 3)
XCTAssertEqual(diagnostics.lastAttemptAt, d3)
}

func testMarkAttempt_doesNotAffectSuccessFailureCounts() {
diagnostics.markAttempt(at: fixedDate)
XCTAssertEqual(diagnostics.successCount, 0)
XCTAssertEqual(diagnostics.failureCount, 0)
}

// MARK: - markSuccess

func testMarkSuccess_incrementsSuccessCount() {
diagnostics.markSuccess(processCount: 42, topCPUCount: 5, topMemoryCount: 5, at: fixedDate)
XCTAssertEqual(diagnostics.successCount, 1)
}

func testMarkSuccess_setsLastSuccessAt() {
diagnostics.markSuccess(processCount: 42, topCPUCount: 5, topMemoryCount: 5, at: fixedDate)
XCTAssertEqual(diagnostics.lastSuccessAt, fixedDate)
}

func testMarkSuccess_capturesProcessCounts() {
diagnostics.markSuccess(processCount: 42, topCPUCount: 8, topMemoryCount: 12, at: fixedDate)
XCTAssertEqual(diagnostics.lastSnapshotProcessCount, 42)
XCTAssertEqual(diagnostics.lastTopCPUCount, 8)
XCTAssertEqual(diagnostics.lastTopMemoryCount, 12)
}

func testMarkSuccess_doesNotAffectAttemptOrFailureCount() {
diagnostics.markSuccess(processCount: 1, topCPUCount: 1, topMemoryCount: 1, at: fixedDate)
XCTAssertEqual(diagnostics.attemptCount, 0)
XCTAssertEqual(diagnostics.failureCount, 0)
}

// MARK: - markFailure

func testMarkFailure_incrementsFailureCount() {
diagnostics.markFailure(message: "ps exited 1", details: "signal 9", at: fixedDate)
XCTAssertEqual(diagnostics.failureCount, 1)
}

func testMarkFailure_setsLastFailureAt() {
diagnostics.markFailure(message: "ps exited 1", details: "signal 9", at: fixedDate)
XCTAssertEqual(diagnostics.lastFailureAt, fixedDate)
}

func testMarkFailure_capturesMessageAndDetails() {
diagnostics.markFailure(message: "access denied", details: "PermissionError", at: fixedDate)
XCTAssertEqual(diagnostics.lastFailureMessage, "access denied")
XCTAssertEqual(diagnostics.lastFailureDetails, "PermissionError")
}

func testMarkFailure_doesNotAffectAttemptOrSuccessCount() {
diagnostics.markFailure(message: "err", details: "detail", at: fixedDate)
XCTAssertEqual(diagnostics.attemptCount, 0)
XCTAssertEqual(diagnostics.successCount, 0)
}

// MARK: - Full lifecycle

func testFullLifecycle_attemptThenSuccess() {
let d1 = Date(timeIntervalSince1970: 10)
let d2 = Date(timeIntervalSince1970: 20)
diagnostics.markAttempt(at: d1)
diagnostics.markSuccess(processCount: 10, topCPUCount: 5, topMemoryCount: 5, at: d2)

XCTAssertEqual(diagnostics.attemptCount, 1)
XCTAssertEqual(diagnostics.successCount, 1)
XCTAssertEqual(diagnostics.failureCount, 0)
XCTAssertEqual(diagnostics.lastAttemptAt, d1)
XCTAssertEqual(diagnostics.lastSuccessAt, d2)
}

func testFullLifecycle_attemptThenFailure() {
let d1 = Date(timeIntervalSince1970: 10)
let d2 = Date(timeIntervalSince1970: 20)
diagnostics.markAttempt(at: d1)
diagnostics.markFailure(message: "err", details: "det", at: d2)

XCTAssertEqual(diagnostics.attemptCount, 1)
XCTAssertEqual(diagnostics.successCount, 0)
XCTAssertEqual(diagnostics.failureCount, 1)
XCTAssertEqual(diagnostics.lastAttemptAt, d1)
XCTAssertEqual(diagnostics.lastFailureAt, d2)
XCTAssertEqual(diagnostics.lastFailureMessage, "err")
}
}
Loading