diff --git a/Sources/ProcessBarMonitor/PowerSourceProvider.swift b/Sources/ProcessBarMonitor/PowerSourceProvider.swift new file mode 100644 index 0000000..723034d --- /dev/null +++ b/Sources/ProcessBarMonitor/PowerSourceProvider.swift @@ -0,0 +1,56 @@ +import Foundation +import IOKit.ps + +struct PowerSourceProvider { + func getPowerSourceInfo() -> PowerSourceInfo { + guard let powerSourceInfo = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(), + let powerSourceList = IOPSCopyPowerSourcesList(powerSourceInfo)?.takeRetainedValue() as? [CFTypeRef], + !powerSourceList.isEmpty else { + return .unavailable + } + + for source in powerSourceList { + guard let description = IOPSGetPowerSourceDescription(powerSourceInfo, source)?.takeUnretainedValue() as? [String: Any] else { + continue + } + + let type = description[kIOPSTypeKey as String] as? String + guard type == kIOPSInternalBatteryType as String else { continue } + + let currentCapacity = description[kIOPSCurrentCapacityKey as String] as? Int ?? 0 + let maxCapacity = description[kIOPSMaxCapacityKey as String] as? Int ?? 100 + let isCharging = description[kIOPSIsChargingKey as String] as? Bool ?? false + let isPluggedIn = description[kIOPSPowerSourceStateKey as String] as? String == kIOPSACPowerValue as String + + let batteryPercent = maxCapacity > 0 ? (Double(currentCapacity) / Double(maxCapacity)) * 100 : 0 + + let timeRemaining: Int? + if let secs = description[kIOPSTimeToEmptyKey as String] as? Int, secs > 0 { + timeRemaining = secs + } else if let secs = description[kIOPSTimeToFullChargeKey as String] as? Int, secs > 0 { + timeRemaining = secs + } else { + timeRemaining = nil + } + + let status: BatteryStatus + if isCharging { + status = .charging + } else if batteryPercent >= 99 { + status = .full + } else { + status = .discharging + } + + return PowerSourceInfo( + batteryPercent: batteryPercent, + status: status, + isPluggedIn: isPluggedIn, + timeRemaining: timeRemaining, + hasBattery: true + ) + } + + return .unavailable + } +} \ No newline at end of file diff --git a/Sources/ProcessBarMonitor/Resources/en.lproj/Localizable.strings b/Sources/ProcessBarMonitor/Resources/en.lproj/Localizable.strings index cde49f2..0881933 100644 --- a/Sources/ProcessBarMonitor/Resources/en.lproj/Localizable.strings +++ b/Sources/ProcessBarMonitor/Resources/en.lproj/Localizable.strings @@ -81,3 +81,11 @@ "toggle.top_memory" = "Top Memory Apps"; "toggle.temperature_hint" = "Temperature Hint"; "toggle.diagnostics" = "Diagnostics"; +"toggle.power" = "Battery"; +"summary.battery" = "Battery"; +"battery.charging" = "Charging"; +"battery.discharging" = "On Battery"; +"battery.full" = "Full"; +"battery.no_battery" = "No Battery"; +"battery.time_remaining_format" = "%dh %dm remaining"; +"battery.time_remaining_unavailable" = "Time unavailable"; diff --git a/Sources/ProcessBarMonitor/Resources/zh-Hans.lproj/Localizable.strings b/Sources/ProcessBarMonitor/Resources/zh-Hans.lproj/Localizable.strings index f907113..7146167 100644 --- a/Sources/ProcessBarMonitor/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/ProcessBarMonitor/Resources/zh-Hans.lproj/Localizable.strings @@ -81,3 +81,11 @@ "toggle.top_memory" = "内存占用最高应用"; "toggle.temperature_hint" = "温度提示"; "toggle.diagnostics" = "诊断信息"; +"toggle.power" = "电池"; +"summary.battery" = "电池"; +"battery.charging" = "充电中"; +"battery.discharging" = "使用电池"; +"battery.full" = "已充满"; +"battery.no_battery" = "无电池"; +"battery.time_remaining_format" = "剩余 %dh %dm"; +"battery.time_remaining_unavailable" = "无法估算"; diff --git a/Sources/ProcessBarMonitor/SettingsView.swift b/Sources/ProcessBarMonitor/SettingsView.swift index 47ac53c..9b0f983 100644 --- a/Sources/ProcessBarMonitor/SettingsView.swift +++ b/Sources/ProcessBarMonitor/SettingsView.swift @@ -61,6 +61,10 @@ struct SettingsView: View { get: { viewModel.moduleVisibility.contains(.diagnostics) }, set: { viewModel.moduleVisibility = $0 ? viewModel.moduleVisibility.union(.diagnostics) : viewModel.moduleVisibility.subtracting(.diagnostics) } )) + Toggle(L10n.string("toggle.power"), isOn: Binding( + get: { viewModel.moduleVisibility.contains(.power) }, + set: { viewModel.moduleVisibility = $0 ? viewModel.moduleVisibility.union(.power) : viewModel.moduleVisibility.subtracting(.power) } + )) } header: { Text(L10n.string("settings.section.modules")) } diff --git a/Sources/ProcessBarMonitor/SystemMetricsProvider.swift b/Sources/ProcessBarMonitor/SystemMetricsProvider.swift index 9478ef5..27751cc 100644 --- a/Sources/ProcessBarMonitor/SystemMetricsProvider.swift +++ b/Sources/ProcessBarMonitor/SystemMetricsProvider.swift @@ -26,6 +26,8 @@ actor SystemMetricsProvider { case osxCpuTemp } + private let powerSourceProvider = PowerSourceProvider() + func snapshot(temperatureMode: TemperatureMode) -> SystemSummary { let temperature = bestEffortCPUTemperature(mode: temperatureMode) @@ -38,6 +40,7 @@ actor SystemMetricsProvider { cpuTemperatureC: temperature, architectureNote: architectureAndTemperatureNote(temperatureAvailable: temperature != nil, mode: temperatureMode), temperatureHint: temperatureHint(temperatureAvailable: temperature != nil), + powerSource: powerSourceProvider.getPowerSourceInfo(), updatedAt: Date() ) } diff --git a/Sources/ProcessBarMonitor/SystemModels.swift b/Sources/ProcessBarMonitor/SystemModels.swift index 666fc13..3b5a18a 100644 --- a/Sources/ProcessBarMonitor/SystemModels.swift +++ b/Sources/ProcessBarMonitor/SystemModels.swift @@ -164,14 +164,63 @@ struct PopupModuleVisibility: OptionSet, Equatable { static let topMemory = PopupModuleVisibility(rawValue: 1 << 2) static let temperatureHint = PopupModuleVisibility(rawValue: 1 << 3) static let diagnostics = PopupModuleVisibility(rawValue: 1 << 4) + static let power = PopupModuleVisibility(rawValue: 1 << 5) - static let all: PopupModuleVisibility = [.sparklines, .topCPU, .topMemory, .temperatureHint, .diagnostics] + static let all: PopupModuleVisibility = [.sparklines, .topCPU, .topMemory, .temperatureHint, .diagnostics, .power] static var defaultVisibility: PopupModuleVisibility { [.sparklines, .topCPU, .topMemory] } } +enum BatteryStatus: String { + case charging + case discharging + case full + case noBattery + + var title: String { + switch self { + case .charging: return L10n.string("battery.charging") + case .discharging: return L10n.string("battery.discharging") + case .full: return L10n.string("battery.full") + case .noBattery: return L10n.string("battery.no_battery") + } + } +} + +struct PowerSourceInfo: Equatable { + let batteryPercent: Double + let status: BatteryStatus + let isPluggedIn: Bool + /// Time remaining in seconds, nil if unavailable. + let timeRemaining: Int? + /// True if battery is present on this device. + let hasBattery: Bool + + var timeRemainingText: String { + guard let seconds = timeRemaining else { + return L10n.string("battery.time_remaining_unavailable") + } + if seconds <= 0 { + return L10n.string("battery.time_remaining_unavailable") + } + let hours = seconds / 3600 + let minutes = (seconds % 3600) / 60 + return L10n.format("battery.time_remaining_format", hours, minutes) + } + + static var unavailable: PowerSourceInfo { + PowerSourceInfo( + batteryPercent: 0, + status: .noBattery, + isPluggedIn: false, + timeRemaining: nil, + hasBattery: false + ) + } +} + struct ProcessChildStat: Identifiable, Hashable { let pid: Int let command: String @@ -238,6 +287,7 @@ struct SystemSummary { /// Actionable hint shown when temperature is unavailable (nil). /// - Intel without tool: install hint; Apple Silicon read-fail: permission check hint. let temperatureHint: String? + let powerSource: PowerSourceInfo let updatedAt: Date var memoryPressurePercent: Double { @@ -254,6 +304,7 @@ struct SystemSummary { cpuTemperatureC: nil, architectureNote: "", temperatureHint: nil, + powerSource: .unavailable, updatedAt: Date() ) } diff --git a/Sources/ProcessBarMonitor/Views.swift b/Sources/ProcessBarMonitor/Views.swift index 92eb894..3062529 100644 --- a/Sources/ProcessBarMonitor/Views.swift +++ b/Sources/ProcessBarMonitor/Views.swift @@ -179,6 +179,10 @@ struct MenuBarContentView: View { SummaryCardView(title: L10n.string("summary.thermal"), value: viewModel.thermalText(viewModel.summary.thermalState), accent: .pink) } + if viewModel.moduleVisibility.contains(.power) && viewModel.summary.powerSource.hasBattery { + batterySection + } + if viewModel.moduleVisibility.contains(.temperatureHint) { VStack(alignment: .leading, spacing: 4) { Text(healthLine) @@ -363,6 +367,26 @@ Divider() } } + private var batterySection: some View { + let power = viewModel.summary.powerSource + let accent: Color = power.isPluggedIn ? .green : (power.batteryPercent < 20 ? .red : .orange) + return HStack(spacing: 8) { + SummaryCardView( + title: L10n.string("summary.battery"), + value: String(format: "%.0f%%", power.batteryPercent), + subtitle: power.status.title, + accent: accent + ) + if let timeText = power.timeRemainingText as String? { + SummaryCardView( + title: "", + value: timeText, + accent: .secondary + ) + } + } + } + private var processHeader: some View { HStack(spacing: 8) { Text(L10n.string("header.app")) diff --git a/Tests/ProcessBarMonitorTests/PowerSourceTests.swift b/Tests/ProcessBarMonitorTests/PowerSourceTests.swift new file mode 100644 index 0000000..83603b9 --- /dev/null +++ b/Tests/ProcessBarMonitorTests/PowerSourceTests.swift @@ -0,0 +1,69 @@ +import XCTest +@testable import ProcessBarMonitor + +/// Tests for PowerSourceInfo and BatteryStatus parsing and formatting. +final class PowerSourceTests: XCTestCase { + + // MARK: - PowerSourceInfo.timeRemainingText + + func testTimeRemainingText_withSeconds_returnsFormatted() { + let info = PowerSourceInfo( + batteryPercent: 50, + status: .discharging, + isPluggedIn: false, + timeRemaining: 3661, // 1h 1m 1s + hasBattery: true + ) + let expected = L10n.format("battery.time_remaining_format", 1, 1) + XCTAssertEqual(info.timeRemainingText, expected) + } + + func testTimeRemainingText_withZeroSeconds_returnsUnavailable() { + let info = PowerSourceInfo( + batteryPercent: 50, + status: .discharging, + isPluggedIn: false, + timeRemaining: 0, + hasBattery: true + ) + XCTAssertEqual(info.timeRemainingText, L10n.string("battery.time_remaining_unavailable")) + } + + func testTimeRemainingText_withNil_returnsUnavailable() { + let info = PowerSourceInfo( + batteryPercent: 50, + status: .discharging, + isPluggedIn: false, + timeRemaining: nil, + hasBattery: true + ) + XCTAssertEqual(info.timeRemainingText, L10n.string("battery.time_remaining_unavailable")) + } + + func testTimeRemainingText_fullCharge_returnsUnavailable() { + let info = PowerSourceInfo( + batteryPercent: 100, + status: .full, + isPluggedIn: true, + timeRemaining: 0, + hasBattery: true + ) + XCTAssertEqual(info.timeRemainingText, L10n.string("battery.time_remaining_unavailable")) + } + + // MARK: - PowerSourceInfo.unavailable + + func testUnavailable_hasBatteryFalse() { + XCTAssertFalse(PowerSourceInfo.unavailable.hasBattery) + XCTAssertEqual(PowerSourceInfo.unavailable.status, .noBattery) + } + + // MARK: - BatteryStatus.title localization smoke test + + func testBatteryStatus_title_notEmpty() { + XCTAssertFalse(BatteryStatus.charging.title.isEmpty) + XCTAssertFalse(BatteryStatus.discharging.title.isEmpty) + XCTAssertFalse(BatteryStatus.full.title.isEmpty) + XCTAssertFalse(BatteryStatus.noBattery.title.isEmpty) + } +} \ No newline at end of file