From e97454f8605aa490f1a9afc9085d35e580cfae56 Mon Sep 17 00:00:00 2001 From: Sergei Manvelov Date: Fri, 26 Jun 2026 20:45:09 +0700 Subject: [PATCH 1/4] Warn when battery drains faster than usual Add an early warning for abnormally fast battery discharge (issue #14). The helper keeps a ~15 min sliding window of capacity readings on the session and flags abnormal drain when the recent rate is >=1.5x the session average, gated on session maturity (>=30 min), a minimum window span (>=5 min of data), and a homogeneous energy mode across the window so toggling Low Power Mode doesn't trigger false positives. The app reads the flag, posts a macOS notification on the rising edge (with a 30 min cooldown), shows a small warning marker in the session status text, and exposes a "Warn on Fast Battery Drain" toggle in the energy mode menu (default on). New persisted fields are Optional so existing state JSON still decodes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../BatteryTrackerEngine.swift | 65 ++++++++++++++++++- BatteryTrackingShared/BatterySession.swift | 13 ++++ StillCore/BatteryEnergyModeMenu.swift | 14 ++++ StillCore/BatteryTrackerService.swift | 58 ++++++++++++++++- StillCore/StillCoreApp.swift | 31 ++++++++- 5 files changed, 178 insertions(+), 3 deletions(-) diff --git a/BatteryTrackerHelper/BatteryTrackerEngine.swift b/BatteryTrackerHelper/BatteryTrackerEngine.swift index 6f7ceef..92c5e24 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 @@ -43,14 +49,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 +69,32 @@ 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 + ) + // 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 +104,42 @@ 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 (e.g. toggling Low Power Mode) doesn't masquerade as abnormal drain. + let windowSpan = newest.at.timeIntervalSince(oldest.at) + guard windowSpan >= anomalyMinWindow else { return false } + guard history.allSatisfy({ $0.powerSaveMode == batteryStatus.powerSaveMode }) 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, diff --git a/BatteryTrackingShared/BatterySession.swift b/BatteryTrackingShared/BatterySession.swift index c3d4be0..57fd29d 100644 --- a/BatteryTrackingShared/BatterySession.swift +++ b/BatteryTrackingShared/BatterySession.swift @@ -18,11 +18,21 @@ enum BatteryTrackerConstants { static let heartbeatTimeout: TimeInterval = 15 } +struct BatteryCapacityReading: Codable { + var at: Date + var capacityMah: Int + var powerSaveMode: 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 +42,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/StillCore/BatteryEnergyModeMenu.swift b/StillCore/BatteryEnergyModeMenu.swift index 78bbe1c..3528960 100644 --- a/StillCore/BatteryEnergyModeMenu.swift +++ b/StillCore/BatteryEnergyModeMenu.swift @@ -60,6 +60,15 @@ final class BatteryEnergyModeMenuController: NSObject { title: "Power Save", state: batteryState, powerSaveMode: true, tag: EnergyModeTag.powerSave )) menu.addItem(.separator()) + let warningItem = NSMenuItem( + title: "Warn on Fast Battery Drain", + action: #selector(toggleAbnormalDrainWarning(_:)), + keyEquivalent: "" + ) + warningItem.target = self + warningItem.state = AppSettings.abnormalDrainWarningEnabled ? .on : .off + menu.addItem(warningItem) + menu.addItem(.separator()) let batterySettingsItem = NSMenuItem( title: "Battery Settings...", action: #selector(openBatterySettings(_:)), @@ -70,6 +79,11 @@ final class BatteryEnergyModeMenuController: NSObject { return menu } + @objc private func toggleAbnormalDrainWarning(_ sender: NSMenuItem) { + AppSettings.abnormalDrainWarningEnabled.toggle() + sender.state = AppSettings.abnormalDrainWarningEnabled ? .on : .off + } + private func makeEnergyModeItem( title: String, state: BatteryRuntimeState, diff --git a/StillCore/BatteryTrackerService.swift b/StillCore/BatteryTrackerService.swift index e7aea9b..5e1c43b 100644 --- a/StillCore/BatteryTrackerService.swift +++ b/StillCore/BatteryTrackerService.swift @@ -2,6 +2,7 @@ import AppKit import Combine import Foundation import ServiceManagement +import UserNotifications @MainActor enum BatteryTrackerInstallState: Equatable { @@ -47,6 +48,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 @@ -75,6 +80,13 @@ 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 static let abnormalDrainNotificationIdentifier = "com.github.homm.StillCore.abnormalDrain" + private var lastAbnormalDrain = false + private var lastAbnormalNotifiedAt: Date? + private init(start: Bool) { guard start else { return } refreshAll() @@ -174,6 +186,49 @@ 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() { + let content = UNMutableNotificationContent() + content.title = "Battery draining faster than usual" + if let runtimeState, + let usedPercent = runtimeState.usedPercent, + let activeSeconds = runtimeState.activeSeconds { + content.body = "Recent drain is well above this session's average " + + "(used \(Int(usedPercent.rounded()))% over \(formatDuration(activeSeconds))). " + + "Check for runaway apps or heavy background tasks." + } else { + content.body = "Recent drain is well above this session's average. " + + "Check for runaway apps or heavy background tasks." + } + content.sound = .default + + // Stable identifier coalesces repeats into a single Notification Center entry. + let request = UNNotificationRequest( + identifier: Self.abnormalDrainNotificationIdentifier, + content: content, + trigger: nil + ) + UNUserNotificationCenter.current().add(request) } var runtimeLabel: String { @@ -216,7 +271,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..c58fb65 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,17 @@ enum AppSettings { UserDefaults.standard.set(newValue, forKey: frequencyUsageByCoresKey) } } + + // Warn when the battery drains noticeably faster than the session average. + // Defaults to true: an unset value (object == nil) reads as enabled. + static var abnormalDrainWarningEnabled: Bool { + get { + UserDefaults.standard.object(forKey: abnormalDrainWarningEnabledKey) as? Bool ?? true + } + set { + UserDefaults.standard.set(newValue, forKey: abnormalDrainWarningEnabledKey) + } + } } enum AppPresentation { @@ -739,7 +752,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 +773,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return } + // Local notifications surface abnormal battery drain. Ask once; the warning + // path is also gated by the AppSettings toggle. + if BatteryTrackerService.isBatteryAvailable { + let center = UNUserNotificationCenter.current() + center.delegate = self + center.requestAuthorization(options: [.alert, .sound]) { _, _ in } + } + updaterController = SPUStandardUpdaterController( startingUpdater: true, updaterDelegate: nil, @@ -819,6 +840,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { @objc private func quitApplication() { NSApp.terminate(nil) } + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .sound]) + } } @MainActor From 5c0f4312ac54ec6a0355a0601d90d6fbbc459649 Mon Sep 17 00:00:00 2001 From: Sergei Manvelov Date: Fri, 26 Jun 2026 21:33:55 +0700 Subject: [PATCH 2/4] Suppress fast-drain warnings caused by High Power Mode Extend the energy-mode guard to cover High Power Mode (16" MacBook Pro with Max chips), which intentionally raises drain and could otherwise trip a false "abnormal drain" warning when toggled mid-session. High Power Mode has no public API, so the helper reads it from the `powermode` field of `pmset -g custom` (Battery Power section), throttled to once per 60s to keep the daemon light. Detection fails safe: anything other than an explicit `powermode 2` -- including the field being absent on the vast majority of Macs that lack the feature -- reads as not-high, so those Macs behave exactly as before. The per-reading highPowerMode is Optional for backward-compatible decode, and the window-homogeneity check now requires both Low Power and High Power state to be constant. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../BatteryTrackerEngine.swift | 79 ++++++++++++++++++- BatteryTrackingShared/BatterySession.swift | 2 + BatteryTrackingShared/BatteryStatus.swift | 3 + 3 files changed, 80 insertions(+), 4 deletions(-) diff --git a/BatteryTrackerHelper/BatteryTrackerEngine.swift b/BatteryTrackerHelper/BatteryTrackerEngine.swift index 92c5e24..6d6b2a9 100644 --- a/BatteryTrackerHelper/BatteryTrackerEngine.swift +++ b/BatteryTrackerHelper/BatteryTrackerEngine.swift @@ -22,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 { @@ -80,7 +83,8 @@ struct BatteryTrackerEngine { let reading = BatteryCapacityReading( at: now, capacityMah: batteryStatus.currentCapacityMah, - powerSaveMode: batteryStatus.powerSaveMode + powerSaveMode: batteryStatus.powerSaveMode, + highPowerMode: batteryStatus.highPowerMode ) // A sleep gap makes the trailing rate meaningless; start the window fresh. var history = sleepGapDetected ? [] : (activeSession.capacityHistory ?? []) @@ -123,10 +127,15 @@ struct BatteryTrackerEngine { 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 (e.g. toggling Low Power Mode) doesn't masquerade as abnormal drain. + // 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 } - guard history.allSatisfy({ $0.powerSaveMode == batteryStatus.powerSaveMode }) 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 } @@ -167,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 57fd29d..11dd1d1 100644 --- a/BatteryTrackingShared/BatterySession.swift +++ b/BatteryTrackingShared/BatterySession.swift @@ -22,6 +22,8 @@ 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 { 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() From f89b44c85788098e670a7acb63f0129f74955c71 Mon Sep 17 00:00:00 2001 From: Sergei Manvelov Date: Fri, 26 Jun 2026 21:43:44 +0700 Subject: [PATCH 3/4] Refine drain-warning UX: calmer tone, deferred consent, actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the abnormal-drain warning feel like a calm system advisory rather than a utility alert. - Defer notification permission request until the user enables the warning or a warning first needs to be shown, instead of prompting at launch. - Drop notification sound (banner only) for a calmer advisory tone. - Reword copy to "Battery Is Draining Quickly" / "StillCore noticed higher power use over the last few minutes." - Rename menu item to "Notify When Battery Drains Quickly". - Add "Open Activity Monitor" and "Battery Settings" notification actions via a registered category. - Reflect denied notification permission in the energy menu with an explanation and an "Open Notification Settings…" shortcut. --- StillCore/BatteryEnergyModeMenu.swift | 37 +++++++++++++++- StillCore/BatteryTrackerService.swift | 64 +++++++++++++++++++++------ StillCore/StillCoreApp.swift | 55 +++++++++++++++++++++-- 3 files changed, 136 insertions(+), 20 deletions(-) diff --git a/StillCore/BatteryEnergyModeMenu.swift b/StillCore/BatteryEnergyModeMenu.swift index 3528960..21f4544 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,14 +62,34 @@ final class BatteryEnergyModeMenuController: NSObject { title: "Power Save", state: batteryState, powerSaveMode: true, tag: EnergyModeTag.powerSave )) menu.addItem(.separator()) + let warningEnabled = AppSettings.abnormalDrainWarningEnabled let warningItem = NSMenuItem( - title: "Warn on Fast Battery Drain", + title: "Notify When Battery Drains Quickly", action: #selector(toggleAbnormalDrainWarning(_:)), keyEquivalent: "" ) warningItem.target = self - warningItem.state = AppSettings.abnormalDrainWarningEnabled ? .on : .off + warningItem.state = warningEnabled ? .on : .off menu.addItem(warningItem) + + // When the warning is on but the system has notifications turned off, the + // warning can't reach the user. Explain it and offer a way to fix it. + if warningEnabled, BatteryTrackerService.shared.notificationAuthorization == .denied { + let explanation = NSMenuItem( + title: "Notifications are turned off in System Settings", + action: nil, + keyEquivalent: "" + ) + explanation.isEnabled = false + menu.addItem(explanation) + let openNotificationSettingsItem = NSMenuItem( + title: "Open Notification Settings…", + action: #selector(openNotificationSettings(_:)), + keyEquivalent: "" + ) + openNotificationSettingsItem.target = self + menu.addItem(openNotificationSettingsItem) + } menu.addItem(.separator()) let batterySettingsItem = NSMenuItem( title: "Battery Settings...", @@ -82,6 +104,17 @@ final class BatteryEnergyModeMenuController: NSObject { @objc private func toggleAbnormalDrainWarning(_ sender: NSMenuItem) { AppSettings.abnormalDrainWarningEnabled.toggle() sender.state = AppSettings.abnormalDrainWarningEnabled ? .on : .off + if AppSettings.abnormalDrainWarningEnabled { + // Opt-in is the contextually right moment to ask for permission. + BatteryTrackerService.shared.requestNotificationAuthorization() + } + } + + @objc private func openNotificationSettings(_ sender: NSMenuItem) { + guard let url = URL( + string: "x-apple.systempreferences:com.apple.Notifications-Settings.extension" + ) else { return } + NSWorkspace.shared.open(url) } private func makeEnergyModeItem( diff --git a/StillCore/BatteryTrackerService.swift b/StillCore/BatteryTrackerService.swift index 5e1c43b..ebfa18a 100644 --- a/StillCore/BatteryTrackerService.swift +++ b/StillCore/BatteryTrackerService.swift @@ -4,6 +4,16 @@ 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 { case notInstalled @@ -70,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() @@ -83,7 +97,6 @@ final class BatteryTrackerService: ObservableObject { // 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 static let abnormalDrainNotificationIdentifier = "com.github.homm.StillCore.abnormalDrain" private var lastAbnormalDrain = false private var lastAbnormalNotifiedAt: Date? @@ -208,29 +221,52 @@ final class BatteryTrackerService: ObservableObject { } private func postAbnormalDrainNotification() { - let content = UNMutableNotificationContent() - content.title = "Battery draining faster than usual" - if let runtimeState, - let usedPercent = runtimeState.usedPercent, - let activeSeconds = runtimeState.activeSeconds { - content.body = "Recent drain is well above this session's average " - + "(used \(Int(usedPercent.rounded()))% over \(formatDuration(activeSeconds))). " - + "Check for runaway apps or heavy background tasks." - } else { - content.body = "Recent drain is well above this session's average. " - + "Check for runaway apps or heavy background tasks." + // 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() + } } - content.sound = .default + } + + 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: Self.abnormalDrainNotificationIdentifier, + 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. + func requestNotificationAuthorization() { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert]) { [weak self] _, _ in + Task { @MainActor in self?.refreshNotificationAuthorization() } + } + } + var runtimeLabel: String { switch installState { case .notInstalled: diff --git a/StillCore/StillCoreApp.swift b/StillCore/StillCoreApp.swift index c58fb65..69c389a 100644 --- a/StillCore/StillCoreApp.swift +++ b/StillCore/StillCoreApp.swift @@ -773,12 +773,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return } - // Local notifications surface abnormal battery drain. Ask once; the warning - // path is also gated by the AppSettings toggle. + // 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.requestAuthorization(options: [.alert, .sound]) { _, _ in } + center.setNotificationCategories([Self.abnormalDrainCategory()]) + BatteryTrackerService.shared.refreshNotificationAuthorization() } updaterController = SPUStandardUpdaterController( @@ -841,12 +844,56 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent 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 ) { - completionHandler([.banner, .sound]) + // 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()) } } From 7ea05686f1a77fff0b45cf4c8d090848b3bd4827 Mon Sep 17 00:00:00 2001 From: Sergei Manvelov Date: Fri, 26 Jun 2026 23:30:06 +0700 Subject: [PATCH 4/4] Collapse drain-warning notification UI into one toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The battery menu showed three items for the drain warning: the toggle, a "Notifications are turned off in System Settings" label, and an "Open Notification Settings…" action. Replace them with a single toggle whose checkmark reflects whether the warning can actually reach the user. - Default the warning to off so the user opts in explicitly. - Enabling requests notification permission and only checks the item if granted; if previously denied, route to System Settings instead. - Gate the checkmark on the live authorization status so it shows off again when notifications are revoked. - Have requestNotificationAuthorization report whether permission was granted so the menu can reflect the result. --- StillCore/BatteryEnergyModeMenu.swift | 63 +++++++++++++++------------ StillCore/BatteryTrackerService.swift | 12 +++-- StillCore/StillCoreApp.swift | 5 ++- 3 files changed, 47 insertions(+), 33 deletions(-) diff --git a/StillCore/BatteryEnergyModeMenu.swift b/StillCore/BatteryEnergyModeMenu.swift index 21f4544..62783de 100644 --- a/StillCore/BatteryEnergyModeMenu.swift +++ b/StillCore/BatteryEnergyModeMenu.swift @@ -62,34 +62,16 @@ final class BatteryEnergyModeMenuController: NSObject { title: "Power Save", state: batteryState, powerSaveMode: true, tag: EnergyModeTag.powerSave )) menu.addItem(.separator()) - let warningEnabled = AppSettings.abnormalDrainWarningEnabled + // 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 = warningEnabled ? .on : .off + warningItem.state = abnormalDrainWarningActive ? .on : .off menu.addItem(warningItem) - - // When the warning is on but the system has notifications turned off, the - // warning can't reach the user. Explain it and offer a way to fix it. - if warningEnabled, BatteryTrackerService.shared.notificationAuthorization == .denied { - let explanation = NSMenuItem( - title: "Notifications are turned off in System Settings", - action: nil, - keyEquivalent: "" - ) - explanation.isEnabled = false - menu.addItem(explanation) - let openNotificationSettingsItem = NSMenuItem( - title: "Open Notification Settings…", - action: #selector(openNotificationSettings(_:)), - keyEquivalent: "" - ) - openNotificationSettingsItem.target = self - menu.addItem(openNotificationSettingsItem) - } menu.addItem(.separator()) let batterySettingsItem = NSMenuItem( title: "Battery Settings...", @@ -101,16 +83,43 @@ 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) { - AppSettings.abnormalDrainWarningEnabled.toggle() - sender.state = AppSettings.abnormalDrainWarningEnabled ? .on : .off - if AppSettings.abnormalDrainWarningEnabled { - // Opt-in is the contextually right moment to ask for permission. - BatteryTrackerService.shared.requestNotificationAuthorization() + 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 } } - @objc private func openNotificationSettings(_ sender: NSMenuItem) { + private func openNotificationSettings() { guard let url = URL( string: "x-apple.systempreferences:com.apple.Notifications-Settings.extension" ) else { return } diff --git a/StillCore/BatteryTrackerService.swift b/StillCore/BatteryTrackerService.swift index ebfa18a..8138343 100644 --- a/StillCore/BatteryTrackerService.swift +++ b/StillCore/BatteryTrackerService.swift @@ -260,10 +260,14 @@ final class BatteryTrackerService: ObservableObject { } /// Requests notification permission at a moment the user has clearly opted in - /// (e.g. enabling the warning). Only prompts while the status is undetermined. - func requestNotificationAuthorization() { - UNUserNotificationCenter.current().requestAuthorization(options: [.alert]) { [weak self] _, _ in - Task { @MainActor in self?.refreshNotificationAuthorization() } + /// (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) + } } } diff --git a/StillCore/StillCoreApp.swift b/StillCore/StillCoreApp.swift index 69c389a..a295d3c 100644 --- a/StillCore/StillCoreApp.swift +++ b/StillCore/StillCoreApp.swift @@ -41,10 +41,11 @@ enum AppSettings { } // Warn when the battery drains noticeably faster than the session average. - // Defaults to true: an unset value (object == nil) reads as enabled. + // 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 ?? true + UserDefaults.standard.object(forKey: abnormalDrainWarningEnabledKey) as? Bool ?? false } set { UserDefaults.standard.set(newValue, forKey: abnormalDrainWarningEnabledKey)