Skip to content
Open
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
56 changes: 56 additions & 0 deletions Sources/ProcessBarMonitor/PowerSourceProvider.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Original file line number Diff line number Diff line change
Expand Up @@ -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" = "无法估算";
4 changes: 4 additions & 0 deletions Sources/ProcessBarMonitor/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
Expand Down
3 changes: 3 additions & 0 deletions Sources/ProcessBarMonitor/SystemMetricsProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ actor SystemMetricsProvider {
case osxCpuTemp
}

private let powerSourceProvider = PowerSourceProvider()

func snapshot(temperatureMode: TemperatureMode) -> SystemSummary {
let temperature = bestEffortCPUTemperature(mode: temperatureMode)

Expand All @@ -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()
)
}
Expand Down
53 changes: 52 additions & 1 deletion Sources/ProcessBarMonitor/SystemModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -254,6 +304,7 @@ struct SystemSummary {
cpuTemperatureC: nil,
architectureNote: "",
temperatureHint: nil,
powerSource: .unavailable,
updatedAt: Date()
)
}
24 changes: 24 additions & 0 deletions Sources/ProcessBarMonitor/Views.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"))
Expand Down
69 changes: 69 additions & 0 deletions Tests/ProcessBarMonitorTests/PowerSourceTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}