From e456e66b488ee7be98263a24f7cd71f4d1cea6a7 Mon Sep 17 00:00:00 2001 From: LMZ Date: Tue, 21 Apr 2026 19:15:50 +0800 Subject: [PATCH 1/3] docs: align README with current implementation (issue #34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes documentation drift from current implementation: EN README.md: - Notes on CPU temperature: replaces 'falls back to `--`' with Apple Silicon HID priority + actionable hint behavior (no silent `--`) - Roadmap: removes 'Sparklines / history' (already implemented) and plain 'Per-process actions'; keeps only 'Per-process actions (e.g. kill, priority adjustment)' ZH README.zh-CN.md: - 关于 CPU 温度: replaces '回退显示为 `--`' with Apple Silicon HID priority + actionable hint behavior - Roadmap 想法: removes '趋势图 / 历史曲线' and plain '针对进程的操作按钮'; keeps only the clarified wording with examples All outdated lines removed; all new lines added. No code changes. --- README.md | 6 +++--- README.zh-CN.md | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 573de96..1629b9c 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/README.zh-CN.md b/README.zh-CN.md index fd0bfb6..c062ee3 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 ## 已知限制 @@ -70,8 +71,7 @@ macOS 没有为普通应用提供稳定的公开 CPU 温度 API,所以这个 - 这个应用还在继续打磨,离更成熟的公开版本还有一些工作 ## Roadmap 想法 -- 趋势图 / 历史曲线 -- 针对进程的操作按钮 +- 针对进程的操作按钮(如结束进程、优先级调整) - 更好的传感器集成 - 进一步的性能优化 From 440e94ef016c35c514b8edc4ecb9fcb5e3c05d5c Mon Sep 17 00:00:00 2001 From: LMZ Date: Wed, 29 Apr 2026 17:18:41 +0800 Subject: [PATCH 2/3] feat: add refresh rate presets (power saving / balanced / real-time) for issue #47 - Add RefreshRatePreset enum with powerSaving, balanced, realTime cases - Add refreshRatePreset published property to MonitorViewModel with didSet - Replace hardcoded summaryRefreshInterval and processRefreshInterval with configurable ones - Add refresh rate picker to SettingsView - Add localization strings for all three presets (en/zh-Hans) - Add unit tests for default value and migration scenarios --- .../ProcessBarMonitor/MonitorViewModel.swift | 33 ++++++++++--- .../Resources/en.lproj/Localizable.strings | 4 ++ .../zh-Hans.lproj/Localizable.strings | 4 ++ Sources/ProcessBarMonitor/SettingsView.swift | 7 ++- Sources/ProcessBarMonitor/SystemModels.swift | 49 +++++++++++++++++++ .../MonitorViewModelTests.swift | 49 +++++++++++++++++++ 6 files changed, 137 insertions(+), 9 deletions(-) diff --git a/Sources/ProcessBarMonitor/MonitorViewModel.swift b/Sources/ProcessBarMonitor/MonitorViewModel.swift index 5ef51e3..7e4d458 100644 --- a/Sources/ProcessBarMonitor/MonitorViewModel.swift +++ b/Sources/ProcessBarMonitor/MonitorViewModel.swift @@ -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] = [] @@ -41,25 +39,30 @@ 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() + } + } + + private var currentSummaryRefreshInterval: UInt64 = 2_000_000_000 + private var currentProcessRefreshInterval: TimeInterval = 10 private let metricsProvider = SystemMetricsProvider() private let processProvider = ProcessSnapshotProvider.shared @@ -74,6 +77,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) { @@ -96,6 +100,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 { @@ -119,6 +130,11 @@ final class MonitorViewModel: ObservableObject { return String(format: "%.1fG", gb) } + private func applyRefreshRatePreset() { + currentSummaryRefreshInterval = refreshRatePreset.summaryInterval + currentProcessRefreshInterval = refreshRatePreset.processInterval + } + func start() { guard refreshTask == nil else { return } @@ -131,12 +147,13 @@ 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) + try? await Task.sleep(nanoseconds: self.currentSummaryRefreshInterval) await self.refresh() } } @@ -159,7 +176,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) diff --git a/Sources/ProcessBarMonitor/Resources/en.lproj/Localizable.strings b/Sources/ProcessBarMonitor/Resources/en.lproj/Localizable.strings index 19ebcbf..2d33fae 100644 --- a/Sources/ProcessBarMonitor/Resources/en.lproj/Localizable.strings +++ b/Sources/ProcessBarMonitor/Resources/en.lproj/Localizable.strings @@ -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"; diff --git a/Sources/ProcessBarMonitor/Resources/zh-Hans.lproj/Localizable.strings b/Sources/ProcessBarMonitor/Resources/zh-Hans.lproj/Localizable.strings index 072f809..8084e0f 100644 --- a/Sources/ProcessBarMonitor/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/ProcessBarMonitor/Resources/zh-Hans.lproj/Localizable.strings @@ -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" = "实时"; diff --git a/Sources/ProcessBarMonitor/SettingsView.swift b/Sources/ProcessBarMonitor/SettingsView.swift index 493d0f6..9577f50 100644 --- a/Sources/ProcessBarMonitor/SettingsView.swift +++ b/Sources/ProcessBarMonitor/SettingsView.swift @@ -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")) } diff --git a/Sources/ProcessBarMonitor/SystemModels.swift b/Sources/ProcessBarMonitor/SystemModels.swift index 022a31b..5375a5e 100644 --- a/Sources/ProcessBarMonitor/SystemModels.swift +++ b/Sources/ProcessBarMonitor/SystemModels.swift @@ -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 diff --git a/Tests/ProcessBarMonitorTests/MonitorViewModelTests.swift b/Tests/ProcessBarMonitorTests/MonitorViewModelTests.swift index d09eb58..31fecc2 100644 --- a/Tests/ProcessBarMonitorTests/MonitorViewModelTests.swift +++ b/Tests/ProcessBarMonitorTests/MonitorViewModelTests.swift @@ -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") + } } From 7abfd96f89e263a4ecc2dc76c6640950fe950b63 Mon Sep 17 00:00:00 2001 From: LMZ Date: Wed, 29 Apr 2026 17:22:31 +0800 Subject: [PATCH 3/3] fix: restart refresh loop when refreshRatePreset changes so new interval takes effect immediately Before: changing preset while sleeping would only take effect on next sleep cycle. After: cancel existing refresh task and create a new one with the updated interval. Also extracted createRefreshTask() helper to avoid duplication. --- .../ProcessBarMonitor/MonitorViewModel.swift | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/Sources/ProcessBarMonitor/MonitorViewModel.swift b/Sources/ProcessBarMonitor/MonitorViewModel.swift index 7e4d458..df4dd52 100644 --- a/Sources/ProcessBarMonitor/MonitorViewModel.swift +++ b/Sources/ProcessBarMonitor/MonitorViewModel.swift @@ -58,6 +58,10 @@ final class MonitorViewModel: ObservableObject { didSet { settings.set(refreshRatePreset.rawValue, forKey: Keys.refreshRatePreset) applyRefreshRatePreset() + if let existingTask = refreshTask { + existingTask.cancel() + refreshTask = createRefreshTask() + } } } @@ -135,6 +139,17 @@ final class MonitorViewModel: ObservableObject { currentProcessRefreshInterval = refreshRatePreset.processInterval } + private func createRefreshTask() -> Task { + 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 } @@ -149,14 +164,7 @@ 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.currentSummaryRefreshInterval) - await self.refresh() - } - } + refreshTask = createRefreshTask() } func stop() {