diff --git a/BatteryTrackerHelper/BatteryTrackerEngine.swift b/BatteryTrackerHelper/BatteryTrackerEngine.swift index 6f7ceef..6d6b2a9 100644 --- a/BatteryTrackerHelper/BatteryTrackerEngine.swift +++ b/BatteryTrackerHelper/BatteryTrackerEngine.swift @@ -4,6 +4,12 @@ struct BatteryTrackerEngine { private static let pollInterval: TimeInterval = 5 private static let sleepThreshold: TimeInterval = 10 + // Abnormal-drain detection tuning. + private static let anomalyWindow: TimeInterval = 900 // trailing window kept in history (15 min) + private static let anomalyMinWindow: TimeInterval = 300 // min data span before judging (5 min) + private static let anomalyMinSession: TimeInterval = 1800 // min active session before judging (30 min) + private static let anomalyMultiplier: Double = 1.5 // recent rate must be >= this * session average + private let store: BatterySessionStore private let helperVersion: String @@ -16,13 +22,16 @@ struct BatteryTrackerEngine { } func run() -> Never { + // Cache survives loop iterations so `pmset` is only spawned on a slow cadence. + var highPowerModeReader = HighPowerModeReader() while true { autoreleasepool { let cycleStartedAt = Date() do { var state = try store.load() ?? makeState(now: cycleStartedAt) - let batteryStatus = try BatteryStatus.read() + var batteryStatus = try BatteryStatus.read() + batteryStatus.highPowerMode = highPowerModeReader.read(now: cycleStartedAt) state = update(state: state, batteryStatus: batteryStatus, now: cycleStartedAt) try store.save(state) } catch { @@ -43,14 +52,18 @@ struct BatteryTrackerEngine { private func update(state: BatteryTrackerState, batteryStatus: BatteryStatus, now: Date) -> BatteryTrackerState { var nextState = state var session = nextState.session + var sleepGapDetected = false if let previousCheck = session?.lastCheckAt { let elapsed = now.timeIntervalSince(previousCheck) if elapsed > Self.sleepThreshold { session?.sleepSeconds += max(0, Int(elapsed.rounded()) - Int(Self.pollInterval)) + sleepGapDetected = true } } + var abnormalDrainDetected = false + if batteryStatus.isOnACPower { session = nil } else { @@ -59,12 +72,33 @@ struct BatteryTrackerEngine { startedAt: now, startCapacityMah: batteryStatus.currentCapacityMah, sleepSeconds: 0, - lastCheckAt: now + lastCheckAt: now, + capacityHistory: nil ) } if var activeSession = session { activeSession.lastCheckAt = now + + let reading = BatteryCapacityReading( + at: now, + capacityMah: batteryStatus.currentCapacityMah, + powerSaveMode: batteryStatus.powerSaveMode, + highPowerMode: batteryStatus.highPowerMode + ) + // A sleep gap makes the trailing rate meaningless; start the window fresh. + var history = sleepGapDetected ? [] : (activeSession.capacityHistory ?? []) + history.append(reading) + let cutoff = now.addingTimeInterval(-Self.anomalyWindow) + history.removeAll { $0.at < cutoff } + activeSession.capacityHistory = history + + abnormalDrainDetected = Self.evaluateAbnormalDrain( + session: activeSession, + batteryStatus: batteryStatus, + now: now + ) + session = activeSession } } @@ -74,9 +108,47 @@ struct BatteryTrackerEngine { nextState.heartbeatAt = now nextState.session = session nextState.lastError = nil + nextState.abnormalDrainDetected = abnormalDrainDetected return nextState } + /// Returns true when the trailing-window drain rate is sustainedly faster than the + /// session average. Pure function of its inputs so it is easy to reason about/test. + static func evaluateAbnormalDrain( + session: BatteryTrackerSession, + batteryStatus: BatteryStatus, + now: Date + ) -> Bool { + // Session must be mature enough for the average to be a trustworthy baseline. + let activeSeconds = Int(now.timeIntervalSince(session.startedAt).rounded()) - session.sleepSeconds + guard Double(activeSeconds) >= anomalyMinSession else { return false } + + let history = session.capacityHistory ?? [] + guard let oldest = history.first, let newest = history.last else { return false } + + // Need a sustained span of recent data, all in the same energy mode so a mode + // switch (toggling Low Power Mode, or flipping into High Power Mode on supported + // Macs) doesn't masquerade as abnormal drain. + let windowSpan = newest.at.timeIntervalSince(oldest.at) + guard windowSpan >= anomalyMinWindow else { return false } + let sameEnergyMode = history.allSatisfy { + $0.powerSaveMode == batteryStatus.powerSaveMode + && ($0.highPowerMode ?? false) == batteryStatus.highPowerMode + } + guard sameEnergyMode else { return false } + + let recentDrainMah = oldest.capacityMah - newest.capacityMah + guard recentDrainMah > 0 else { return false } + let recentRate = Double(recentDrainMah) / windowSpan + + let sessionDrainMah = session.startCapacityMah - batteryStatus.currentCapacityMah + guard sessionDrainMah > 0, activeSeconds > 0 else { return false } + let sessionRate = Double(sessionDrainMah) / Double(activeSeconds) + guard sessionRate > 0 else { return false } + + return recentRate >= anomalyMultiplier * sessionRate + } + private func makeState(now: Date) -> BatteryTrackerState { BatteryTrackerState( helperVersion: helperVersion, @@ -104,3 +176,65 @@ struct BatteryTrackerEngine { ?? "1" } } + +/// Reads macOS High Power Mode, which has no public API. The only signal is the +/// `powermode` field in `pmset -g custom`, present only on Macs that support the +/// feature (16" MacBook Pro with Max chips). Detection fails safe: anything other +/// than an explicit `powermode 2` in the Battery Power section ⇒ false, so Macs +/// without the feature behave exactly as before. The read is throttled because the +/// helper polls every 5s and energy mode changes rarely. +struct HighPowerModeReader { + private static let refreshInterval: TimeInterval = 60 + + private var lastValue = false + private var lastReadAt: Date = .distantPast + + mutating func read(now: Date) -> Bool { + if now.timeIntervalSince(lastReadAt) >= Self.refreshInterval { + lastReadAt = now + lastValue = Self.parseBatteryPowerModeIsHigh(Self.runPmsetCustom() ?? "") + } + return lastValue + } + + /// Parses `pmset -g custom`, returning true only when the Battery Power section + /// reports `powermode 2`. Pure function so it can be unit-checked off-device. + static func parseBatteryPowerModeIsHigh(_ output: String) -> Bool { + var inBatterySection = false + for rawLine in output.split(separator: "\n", omittingEmptySubsequences: false) { + let trimmed = rawLine.trimmingCharacters(in: .whitespaces) + if trimmed == "Battery Power:" { + inBatterySection = true + continue + } + if trimmed.hasSuffix("Power:") { // "AC Power:", "UPS Power:" + inBatterySection = false + continue + } + guard inBatterySection else { continue } + let tokens = trimmed.split(separator: " ", omittingEmptySubsequences: true) + if tokens.count >= 2, tokens[0] == "powermode", let value = Int(tokens[1]) { + return value == 2 + } + } + return false + } + + private static func runPmsetCustom() -> String? { + let process = Process() + let pipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/pmset") + process.arguments = ["-g", "custom"] + process.standardOutput = pipe + process.standardError = Pipe() + do { + try process.run() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + return String(data: data, encoding: .utf8) + } catch { + return nil + } + } +} diff --git a/BatteryTrackingShared/BatterySession.swift b/BatteryTrackingShared/BatterySession.swift index c3d4be0..11dd1d1 100644 --- a/BatteryTrackingShared/BatterySession.swift +++ b/BatteryTrackingShared/BatterySession.swift @@ -18,11 +18,23 @@ enum BatteryTrackerConstants { static let heartbeatTimeout: TimeInterval = 15 } +struct BatteryCapacityReading: Codable { + var at: Date + var capacityMah: Int + var powerSaveMode: Bool + // Optional so readings written before this field existed still decode (nil ⇒ false). + var highPowerMode: Bool? +} + struct BatteryTrackerSession: Codable { var startedAt: Date var startCapacityMah: Int var sleepSeconds: Int var lastCheckAt: Date + // Trailing window of recent readings (bounded to ~15 min) used to detect + // abnormally fast drain within the session. Optional so older on-disk state + // (written before this field existed) still decodes. + var capacityHistory: [BatteryCapacityReading]? } struct BatteryTrackerState: Codable { @@ -32,6 +44,9 @@ struct BatteryTrackerState: Codable { var heartbeatAt: Date = .distantPast var session: BatteryTrackerSession? var lastError: String? + // Set by the helper when the recent drain rate exceeds the session average + // by a sustained margin. Optional for backward-compatible decoding. + var abnormalDrainDetected: Bool? } struct BatterySessionStore { diff --git a/BatteryTrackingShared/BatteryStatus.swift b/BatteryTrackingShared/BatteryStatus.swift index c1f362c..e968f3f 100644 --- a/BatteryTrackingShared/BatteryStatus.swift +++ b/BatteryTrackingShared/BatteryStatus.swift @@ -19,6 +19,9 @@ struct BatteryStatus { var isCharging: Bool var isFullyCharged: Bool var powerSaveMode: Bool + // High Power Mode (16" MacBook Pro with Max chips). No public API exists, so this is + // populated by the helper from `pmset` and defaults to false everywhere else. + var highPowerMode: Bool = false static var isAvailable: Bool { let entry = openBatteryEntry() diff --git a/StillCore/BatteryEnergyModeMenu.swift b/StillCore/BatteryEnergyModeMenu.swift index 78bbe1c..62783de 100644 --- a/StillCore/BatteryEnergyModeMenu.swift +++ b/StillCore/BatteryEnergyModeMenu.swift @@ -33,6 +33,8 @@ final class BatteryEnergyModeMenuController: NSObject { func showMenu() { guard let anchorView, let batteryState else { return } + // Refresh so the next open reflects the current permission state. + BatteryTrackerService.shared.refreshNotificationAuthorization() let menu = menu(for: batteryState) let selectedItem = menu.item( withTag: batteryState.batteryStatus.powerSaveMode ? EnergyModeTag.powerSave : EnergyModeTag.automatic @@ -60,6 +62,17 @@ final class BatteryEnergyModeMenuController: NSObject { title: "Power Save", state: batteryState, powerSaveMode: true, tag: EnergyModeTag.powerSave )) menu.addItem(.separator()) + // The checkmark reflects whether the warning will actually reach the user: + // it shows on only while the warning is enabled and notifications are allowed. + let warningItem = NSMenuItem( + title: "Notify When Battery Drains Quickly", + action: #selector(toggleAbnormalDrainWarning(_:)), + keyEquivalent: "" + ) + warningItem.target = self + warningItem.state = abnormalDrainWarningActive ? .on : .off + menu.addItem(warningItem) + menu.addItem(.separator()) let batterySettingsItem = NSMenuItem( title: "Battery Settings...", action: #selector(openBatterySettings(_:)), @@ -70,6 +83,49 @@ final class BatteryEnergyModeMenuController: NSObject { return menu } + /// Whether the warning is both enabled and able to reach the user. Notifications + /// can be revoked in System Settings after opt-in, so we check the live status. + private var abnormalDrainWarningActive: Bool { + guard AppSettings.abnormalDrainWarningEnabled else { return false } + switch BatteryTrackerService.shared.notificationAuthorization { + case .authorized, .provisional, .ephemeral: + return true + default: + return false + } + } + + @objc private func toggleAbnormalDrainWarning(_ sender: NSMenuItem) { + let service = BatteryTrackerService.shared + if sender.state == .on { + // Already on: simply turn the warning off. + AppSettings.abnormalDrainWarningEnabled = false + sender.state = .off + return + } + + // Turning on. The warning is only meaningful if notifications can be + // delivered, so gate the checkmark on actually getting permission. + if service.notificationAuthorization == .denied { + // The system won't prompt again once denied — point the user at + // System Settings and leave the item unchecked until they grant it. + openNotificationSettings() + return + } + + service.requestNotificationAuthorization { granted in + AppSettings.abnormalDrainWarningEnabled = granted + sender.state = granted ? .on : .off + } + } + + private func openNotificationSettings() { + guard let url = URL( + string: "x-apple.systempreferences:com.apple.Notifications-Settings.extension" + ) else { return } + NSWorkspace.shared.open(url) + } + private func makeEnergyModeItem( title: String, state: BatteryRuntimeState, diff --git a/StillCore/BatteryTrackerService.swift b/StillCore/BatteryTrackerService.swift index e7aea9b..8138343 100644 --- a/StillCore/BatteryTrackerService.swift +++ b/StillCore/BatteryTrackerService.swift @@ -2,6 +2,17 @@ import AppKit import Combine import Foundation import ServiceManagement +import UserNotifications + +// Identifiers for the abnormal-drain notification and its actionable buttons. +// Shared between the poster (BatteryTrackerService) and the delegate that +// registers the category and handles taps (AppDelegate). +enum AbnormalDrainNotification { + static let identifier = "com.github.homm.StillCore.abnormalDrain" + static let categoryIdentifier = "com.github.homm.StillCore.abnormalDrain.category" + static let openActivityMonitorAction = "com.github.homm.StillCore.abnormalDrain.activityMonitor" + static let openBatterySettingsAction = "com.github.homm.StillCore.abnormalDrain.batterySettings" +} @MainActor enum BatteryTrackerInstallState: Equatable { @@ -47,6 +58,10 @@ struct BatteryRuntimeState { guard let usedCapacityMah, batteryStatus.maxCapacityMah > 0 else { return nil } return Double(usedCapacityMah) * 100.0 / Double(batteryStatus.maxCapacityMah) } + + var abnormalDrainDetected: Bool { + batteryTrackerState?.abnormalDrainDetected ?? false + } } @MainActor @@ -65,6 +80,10 @@ final class BatteryTrackerService: ObservableObject { @Published private(set) var runtimeState: BatteryRuntimeState? @Published private(set) var lastErrorMessage: String = "" + // Cached notification permission so the energy menu can reflect a denial without + // an async lookup. Refreshed lazily; never prompts on its own. + @Published private(set) var notificationAuthorization: UNAuthorizationStatus = .notDetermined + // Lets non-SwiftUI code observe runtimeState without exposing write access. var runtimeStatePublisher: AnyPublisher { $runtimeState.eraseToAnyPublisher() @@ -75,6 +94,12 @@ final class BatteryTrackerService: ObservableObject { private var timer: Timer? private var pendingRefreshWorkItem: DispatchWorkItem? + // Abnormal-drain notification: fire once on the rising edge, with a cooldown so a + // flapping flag can't spam the user. + private static let abnormalDrainNotificationCooldown: TimeInterval = 1800 + private var lastAbnormalDrain = false + private var lastAbnormalNotifiedAt: Date? + private init(start: Bool) { guard start else { return } refreshAll() @@ -174,6 +199,76 @@ final class BatteryTrackerService: ObservableObject { } else if !lastErrorMessage.hasPrefix("Install failed:") && !lastErrorMessage.hasPrefix("Uninstall failed:") { lastErrorMessage = "" } + + evaluateAbnormalDrainNotification() + } + + private func evaluateAbnormalDrainNotification() { + let detected = isHelperRunning && (runtimeState?.abnormalDrainDetected ?? false) + defer { lastAbnormalDrain = detected } + + guard detected, !lastAbnormalDrain else { return } + guard AppSettings.abnormalDrainWarningEnabled else { return } + + let now = Date() + if let lastNotifiedAt = lastAbnormalNotifiedAt, + now.timeIntervalSince(lastNotifiedAt) < Self.abnormalDrainNotificationCooldown { + return + } + lastAbnormalNotifiedAt = now + + postAbnormalDrainNotification() + } + + private func postAbnormalDrainNotification() { + // Ask for permission only now, the first time we actually need to warn. + // requestAuthorization is a no-op prompt once the status is determined. + UNUserNotificationCenter.current().requestAuthorization(options: [.alert]) { [weak self] granted, _ in + Task { @MainActor in + guard let self else { return } + self.refreshNotificationAuthorization() + guard granted else { return } + self.deliverAbnormalDrainNotification() + } + } + } + + private func deliverAbnormalDrainNotification() { + let content = UNMutableNotificationContent() + content.title = "Battery Is Draining Quickly" + content.body = "StillCore noticed higher power use over the last few minutes." + // Calm advisory: banner only, no sound. The category adds the action buttons. + content.categoryIdentifier = AbnormalDrainNotification.categoryIdentifier + + // Stable identifier coalesces repeats into a single Notification Center entry. + let request = UNNotificationRequest( + identifier: AbnormalDrainNotification.identifier, + content: content, + trigger: nil + ) + UNUserNotificationCenter.current().add(request) + } + + /// Refreshes the cached notification permission. Never prompts. + func refreshNotificationAuthorization() { + UNUserNotificationCenter.current().getNotificationSettings { [weak self] settings in + let status = settings.authorizationStatus + Task { @MainActor in + self?.notificationAuthorization = status + } + } + } + + /// Requests notification permission at a moment the user has clearly opted in + /// (e.g. enabling the warning). Only prompts while the status is undetermined; + /// reports whether permission ended up granted so the caller can reflect it. + func requestNotificationAuthorization(completion: @escaping @MainActor (Bool) -> Void = { _ in }) { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert]) { [weak self] granted, _ in + Task { @MainActor in + self?.refreshNotificationAuthorization() + completion(granted) + } + } } var runtimeLabel: String { @@ -216,7 +311,8 @@ final class BatteryTrackerService: ObservableObject { } else { sleepSuffix = "" } - return "Drained \(Int(usedPercent.rounded()))% over \(activeDuration)\(sleepSuffix)" + let warningPrefix = runtimeState.abnormalDrainDetected ? "⚠︎ " : "" + return "\(warningPrefix)Drained \(Int(usedPercent.rounded()))% over \(activeDuration)\(sleepSuffix)" } return chargeStatusText(runtimeState.chargeStatus) diff --git a/StillCore/StillCoreApp.swift b/StillCore/StillCoreApp.swift index b49ef25..a295d3c 100644 --- a/StillCore/StillCoreApp.swift +++ b/StillCore/StillCoreApp.swift @@ -3,12 +3,14 @@ import Combine import SwiftUI import MacmonSwift import Sparkle +import UserNotifications enum AppSettings { static let defaultMetricsIntervalMs = 2000 private static let metricsIntervalKey = "metricsIntervalMs" private static let frequencyUsageByCoresKey = "frequencyUsageByCores" private static let statusItemDisplayModeKey = "statusItemDisplayMode" + private static let abnormalDrainWarningEnabledKey = "abnormalDrainWarningEnabled" static var metricsIntervalMs: Int { get { @@ -37,6 +39,18 @@ enum AppSettings { UserDefaults.standard.set(newValue, forKey: frequencyUsageByCoresKey) } } + + // Warn when the battery drains noticeably faster than the session average. + // Defaults to false: the user opts in (and grants notification permission) + // explicitly from the menu. + static var abnormalDrainWarningEnabled: Bool { + get { + UserDefaults.standard.object(forKey: abnormalDrainWarningEnabledKey) as? Bool ?? false + } + set { + UserDefaults.standard.set(newValue, forKey: abnormalDrainWarningEnabledKey) + } + } } enum AppPresentation { @@ -739,7 +753,7 @@ struct ContentView: View { } @MainActor -final class AppDelegate: NSObject, NSApplicationDelegate { +final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate { private var presentationController: MenuPresentationController? private let statusItemMenu = NSMenu() private var statusItemController: StatusItemController? @@ -760,6 +774,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return } + // Local notifications surface abnormal battery drain. Register the delegate + // and the actionable category now (neither prompts the user); permission is + // requested later, only when the user enables the warning or we first need + // to show one. The warning path is also gated by the AppSettings toggle. + if BatteryTrackerService.isBatteryAvailable { + let center = UNUserNotificationCenter.current() + center.delegate = self + center.setNotificationCategories([Self.abnormalDrainCategory()]) + BatteryTrackerService.shared.refreshNotificationAuthorization() + } + updaterController = SPUStandardUpdaterController( startingUpdater: true, updaterDelegate: nil, @@ -819,6 +844,58 @@ final class AppDelegate: NSObject, NSApplicationDelegate { @objc private func quitApplication() { NSApp.terminate(nil) } + + private static func abnormalDrainCategory() -> UNNotificationCategory { + let openActivityMonitor = UNNotificationAction( + identifier: AbnormalDrainNotification.openActivityMonitorAction, + title: "Open Activity Monitor", + options: [.foreground] + ) + let openBatterySettings = UNNotificationAction( + identifier: AbnormalDrainNotification.openBatterySettingsAction, + title: "Battery Settings", + options: [.foreground] + ) + return UNNotificationCategory( + identifier: AbnormalDrainNotification.categoryIdentifier, + actions: [openActivityMonitor, openBatterySettings], + intentIdentifiers: [], + options: [] + ) + } + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + // Calm advisory: show the banner, but stay silent. + completionHandler([.banner]) + } + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + let actionIdentifier = response.actionIdentifier + Task { @MainActor in + switch actionIdentifier { + case AbnormalDrainNotification.openActivityMonitorAction: + Self.openActivityMonitor() + case AbnormalDrainNotification.openBatterySettingsAction: + BatteryTrackerService.shared.openBatterySettings() + default: + break + } + } + completionHandler() + } + + private static func openActivityMonitor() { + let url = URL(fileURLWithPath: "/System/Applications/Utilities/Activity Monitor.app") + NSWorkspace.shared.openApplication(at: url, configuration: NSWorkspace.OpenConfiguration()) + } } @MainActor