From be501ed544fe8af31341617d2eb31e366b343af3 Mon Sep 17 00:00:00 2001 From: Anish S <38997845+git-anish@users.noreply.github.com> Date: Sat, 14 Mar 2026 15:56:59 -0700 Subject: [PATCH 1/2] Add live battery power rate display --- .gitignore | 3 +- README.md | 72 ++++- WhatWatt/AppDelegate.swift | 559 +++++++++++++++++++++++++++++++++---- 3 files changed, 575 insertions(+), 59 deletions(-) diff --git a/.gitignore b/.gitignore index 14ce7f3..ed86553 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -**/xcuserdata/ +.DS_Store +build/ diff --git a/README.md b/README.md index 16f48a5..4eeff97 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,68 @@ -# WhatWatt -USB-C charging can be shrouded in mystery, especially when you have an unfamiliar cable or power adapter, or you have one of those multi-port chargers that has all sorts of arbitrary rules for how it splits power between plugged-in devices. That's why I wrote this little menu bar utility that displays how much power your computer (thinks it) is charging with. I designed this app to be simple and unobtrusive, and light on system resources--it only updates when it detects a change in charging status. +# WhatWatt Power Rate -**Note:** This app can only report what the system is requesting from the charger, which may not necessarily reflect actual values. Think of it as displaying your computer's charging "mode" rather than actually measuring power directly. +A lightweight macOS menu bar app that shows both: +- adapter wattage requested from the charger +- live battery charge or discharge rate -## Compatibility -This app is compatible with Intel and Apple Silicon portable Macs running **macOS 10.13 "High Sierra"** or newer. Although intended for Macs that charge via USB-C/MagSafe 3, this app also partially supports older MagSafe 1 and 2 Macs (though you might not see current/voltage information) +This repo started from [SomeInterestingUserName/WhatWatt](https://github.com/SomeInterestingUserName/WhatWatt) by Jiawei Chen and keeps the original MIT license. -## Installation -Hop on over to the [Releases](https://github.com/SomeInterestingUserName/WhatWatt/releases) to grab your copy of WhatWatt! I'm not enrolled in the Apple Developer Program, so your computer might not trust me. If it refuses to open the app, just right-click and select "Open" from the menu. +## What changed + +This fork adds the behavior requested for day-to-day charger debugging: +- battery charge and discharge rate in watts, derived from `AppleSmartBattery` current and voltage +- event-driven low-power refresh behavior +- default low-power mode: `60s` idle refresh +- burst mode: `1s` refresh for `20s` after a real charger-state change +- menu toggle for `Always Live Updates` +- lightweight `Preferences...` window for tuning live interval, burst duration, and idle interval +- cleaner menu bar title format such as `67W | ↓18.4W` +- Intel-safe signed current handling for wrapped battery amperage values + +## Why this exists + +The original app is excellent for showing negotiated adapter wattage, but it does not expose the current battery power flow. This fork is aimed at people who want to answer questions like: +- Is the battery actually charging right now? +- At what rate is it charging or discharging? +- Did plugging in this cable or charger change only the negotiated adapter wattage, or the real battery power flow too? + +## Telemetry caveat + +Battery power values come from macOS battery telemetry. Even when the app refreshes every second, the underlying battery readings may update more slowly or appear smoothed by the system, so visible changes often land on a cadence closer to a few seconds rather than every exact second. + +## Build + +### With Xcode + +1. Open `WhatWatt.xcodeproj` in Xcode. +2. Select the `WhatWatt` target. +3. Build for the architecture you want on that machine. + +### With Command Line Tools + +On Intel Macs, you can build with `swiftc` and the macOS SDK from Command Line Tools: + +```bash +mkdir -p build/WhatWatt.app/Contents/MacOS build/WhatWatt.app/Contents/Resources +swiftc -sdk /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk -framework Cocoa -framework IOKit WhatWatt/main.swift WhatWatt/AppDelegate.swift WhatWatt/ViewController.swift -o build/WhatWatt.app/Contents/MacOS/WhatWatt +cp WhatWatt/Info.plist build/WhatWatt.app/Contents/Info.plist +codesign --force --deep --sign - build/WhatWatt.app +``` + +## Release strategy + +This project should publish separate binaries instead of pretending one local machine can always produce both cleanly. + +- Intel release: build on Intel and publish an `x86_64` app bundle. +- Apple Silicon release: build on Apple Silicon and publish an `arm64` app bundle. +- Optional universal release: if needed later, combine vetted `x86_64` and `arm64` binaries with `lipo` and package that separately. + +That keeps distribution explicit and avoids shipping untested cross-compiled binaries. + +## License and credit + +Original project: +- Author: Jiawei Chen +- Repo: [SomeInterestingUserName/WhatWatt](https://github.com/SomeInterestingUserName/WhatWatt) +- License: MIT + +This fork remains under the MIT license. See `LICENSE`. diff --git a/WhatWatt/AppDelegate.swift b/WhatWatt/AppDelegate.swift index 8310076..47fca99 100644 --- a/WhatWatt/AppDelegate.swift +++ b/WhatWatt/AppDelegate.swift @@ -5,88 +5,545 @@ // Created by Jiawei Chen on 8/15/22. // - - import Cocoa +import IOKit import IOKit.ps +private enum DefaultsKey { + static let alwaysLiveUpdates = "AlwaysLiveUpdates" + static let liveUpdateInterval = "LiveUpdateInterval" + static let liveBurstDuration = "LiveBurstDuration" + static let idleUpdateInterval = "IdleUpdateInterval" +} + +private struct AppConfiguration { + let alwaysLiveUpdates: Bool + let liveUpdateInterval: TimeInterval + let liveBurstDuration: TimeInterval + let idleUpdateInterval: TimeInterval + + static func current() -> AppConfiguration { + let defaults = UserDefaults.standard + return AppConfiguration( + alwaysLiveUpdates: defaults.bool(forKey: DefaultsKey.alwaysLiveUpdates), + liveUpdateInterval: clamp(defaults.double(forKey: DefaultsKey.liveUpdateInterval), min: 1.0, max: 10.0, fallback: 1.0), + liveBurstDuration: clamp(defaults.double(forKey: DefaultsKey.liveBurstDuration), min: 5.0, max: 300.0, fallback: 20.0), + idleUpdateInterval: clamp(defaults.double(forKey: DefaultsKey.idleUpdateInterval), min: 10.0, max: 300.0, fallback: 60.0) + ) + } + + private static func clamp(_ value: Double, min minValue: Double, max maxValue: Double, fallback: Double) -> Double { + guard value.isFinite, value > 0 else { return fallback } + return Swift.min(maxValue, Swift.max(minValue, value)) + } +} + +private struct AdapterInfo { + let watts: Int + let amps: Double + let volts: Double + let hasValidVoltsAndAmps: Bool +} + +private struct BatteryInfo { + let watts: Double? + let amps: Double? + let volts: Double? + let externalConnected: Bool + let isCharging: Bool + + var flowSymbol: String { + guard let watts else { return "?" } + if watts > 0.05 { return "↑" } + if watts < -0.05 { return "↓" } + return "~" + } +} + +private struct PowerSnapshot { + let adapter: AdapterInfo + let battery: BatteryInfo + + var eventSignature: PowerEventSignature { + PowerEventSignature(externalConnected: battery.externalConnected, + adapterWatts: adapter.watts, + isCharging: battery.isCharging) + } +} + +private struct PowerEventSignature: Equatable { + let externalConnected: Bool + let adapterWatts: Int + let isCharging: Bool +} + +private struct TimerConfiguration: Equatable { + let interval: TimeInterval + let leeway: TimeInterval +} + +private enum RefreshReason { + case launch + case timer + case powerNotification + case preferencesChanged +} + class AppDelegate: NSObject, NSApplicationDelegate { private var statusItem: NSStatusItem! private var chargerDetail: NSMenuItem! - let STR_NOT_CHARGING = "Not Charging" - let STR_CHARGING_BUT_NO_AMPS_VOLTS = "Charging" + private var batteryDetail: NSMenuItem! + private var modeDetail: NSMenuItem! + private var alwaysLiveMenuItem: NSMenuItem! + private var preferencesWindowController: PreferencesWindowController? + private var powerSourceLoopSource: CFRunLoopSource? + private var refreshTimer: DispatchSourceTimer? + private var timerConfiguration: TimerConfiguration? + private var burstEndDate: Date? + private var lastEventSignature: PowerEventSignature? + + private let notChargingText = "Not Charging" + private let chargingText = "Charging" + private let batteryUnknownText = "Battery Rate Unavailable" func applicationDidFinishLaunching(_ aNotification: Notification) { - // Thanks https://sarunw.com/posts/how-to-make-macos-menu-bar-app/ + registerDefaults() + configureMenu() + configurePowerNotifications() + refreshReadings(reason: .launch) + } + + func applicationWillTerminate(_ notification: Notification) { + refreshTimer?.setEventHandler {} + refreshTimer?.cancel() + refreshTimer = nil + + if let powerSourceLoopSource { + CFRunLoopRemoveSource(CFRunLoopGetCurrent(), powerSourceLoopSource, .defaultMode) + } + } + + private func registerDefaults() { + UserDefaults.standard.register(defaults: [ + DefaultsKey.alwaysLiveUpdates: false, + DefaultsKey.liveUpdateInterval: 1.0, + DefaultsKey.liveBurstDuration: 20.0, + DefaultsKey.idleUpdateInterval: 60.0, + ]) + } + + private func configureMenu() { statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - chargerDetail = NSMenuItem(title: STR_NOT_CHARGING, action: nil, keyEquivalent: "") + + chargerDetail = NSMenuItem(title: notChargingText, action: nil, keyEquivalent: "") chargerDetail.isEnabled = false + + batteryDetail = NSMenuItem(title: batteryUnknownText, action: nil, keyEquivalent: "") + batteryDetail.isEnabled = false + + modeDetail = NSMenuItem(title: "Mode: Starting...", action: nil, keyEquivalent: "") + modeDetail.isEnabled = false + + alwaysLiveMenuItem = NSMenuItem(title: "Always Live Updates", action: #selector(toggleAlwaysLiveUpdates(_:)), keyEquivalent: "") + alwaysLiveMenuItem.target = self + + let preferencesItem = NSMenuItem(title: "Preferences...", action: #selector(openPreferences(_:)), keyEquivalent: ",") + preferencesItem.target = self + let menu = NSMenu() menu.addItem(chargerDetail) + menu.addItem(batteryDetail) + menu.addItem(modeDetail) + menu.addItem(NSMenuItem.separator()) + menu.addItem(alwaysLiveMenuItem) + menu.addItem(preferencesItem) menu.addItem(NSMenuItem.separator()) menu.addItem(withTitle: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") statusItem.menu = menu - - // Saves a pointer to this specific instance of AppDelegate, so we can call its member functions in a run loop handler - // Thanks https://stackoverflow.com/questions/38057615/create-a-cfrunloopsourceref-using-iopsnotificationcreaterunloopsource-in-swift and https://stackoverflow.com/questions/50774391/swift-4-getting-thread-1-exc-bad-access-error-with-avfoundation - let context = UnsafeMutableRawPointer(Unmanaged.passRetained(self).toOpaque()) - // Create an async event listener for power source change events (e.g. plugging or unplugging the charger) - let loop: CFRunLoopSource = IOPSNotificationCreateRunLoopSource( - // Updates power - {(context:UnsafeMutableRawPointer?) in - let _self = Unmanaged.fromOpaque(context!).takeUnretainedValue() - _self.powerSourceDidChange() - }, context).takeRetainedValue() as CFRunLoopSource - - // Registers the listener - CFRunLoopAddSource(CFRunLoopGetCurrent(), loop, CFRunLoopMode.defaultMode) - - // Updates the menu bar for the first time - powerSourceDidChange() - } - - // Event handler for power source change - func powerSourceDidChange(){ - let unmanagedDict:Unmanaged? = IOPSCopyExternalPowerAdapterDetails() + + syncMenuState() + } + + private func configurePowerNotifications() { + let context = UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) + powerSourceLoopSource = IOPSNotificationCreateRunLoopSource({ context in + guard let context else { return } + let delegate = Unmanaged.fromOpaque(context).takeUnretainedValue() + delegate.refreshReadings(reason: .powerNotification) + }, context).takeRetainedValue() + + if let powerSourceLoopSource { + CFRunLoopAddSource(CFRunLoopGetCurrent(), powerSourceLoopSource, .defaultMode) + } + } + + @objc private func toggleAlwaysLiveUpdates(_ sender: NSMenuItem) { + let defaults = UserDefaults.standard + defaults.set(!defaults.bool(forKey: DefaultsKey.alwaysLiveUpdates), forKey: DefaultsKey.alwaysLiveUpdates) + applyPreferences() + } + + @objc private func openPreferences(_ sender: Any?) { + if preferencesWindowController == nil { + preferencesWindowController = PreferencesWindowController { [weak self] in + self?.applyPreferences() + } + } + + preferencesWindowController?.reloadFromDefaults() + preferencesWindowController?.showWindow(nil) + NSApp.activate(ignoringOtherApps: true) + } + + private func applyPreferences() { + let configuration = AppConfiguration.current() + if configuration.alwaysLiveUpdates { + burstEndDate = nil + } + syncMenuState() + refreshReadings(reason: .preferencesChanged) + } + + private func refreshReadings(reason: RefreshReason) { + let configuration = AppConfiguration.current() + let snapshot = readPowerSnapshot() + + if reason == .powerNotification, + lastEventSignature != snapshot.eventSignature, + !configuration.alwaysLiveUpdates { + burstEndDate = Date().addingTimeInterval(configuration.liveBurstDuration) + } + + if !configuration.alwaysLiveUpdates, + let burstEndDate, + Date() >= burstEndDate { + self.burstEndDate = nil + } + + lastEventSignature = snapshot.eventSignature + updateUI(with: snapshot, configuration: configuration) + rescheduleTimer(using: configuration) + } + + private func updateUI(with snapshot: PowerSnapshot, configuration: AppConfiguration) { + statusItem.button?.title = menuBarTitle(snapshot: snapshot) + chargerDetail.title = adapterTitle(snapshot.adapter) + batteryDetail.title = batteryTitle(snapshot.battery) + modeDetail.title = currentModeLabel(configuration: configuration) + syncMenuState() + } + + private func syncMenuState() { + let configuration = AppConfiguration.current() + alwaysLiveMenuItem.state = configuration.alwaysLiveUpdates ? .on : .off + } + + private func currentModeLabel(configuration: AppConfiguration) -> String { + if configuration.alwaysLiveUpdates { + return String(format: "Mode: Always live (%.0fs)", configuration.liveUpdateInterval) + } + + if let burstEndDate, burstEndDate > Date() { + let remaining = max(1, Int(ceil(burstEndDate.timeIntervalSinceNow))) + return String(format: "Mode: Event burst live (%.0fs, %ds left)", configuration.liveUpdateInterval, remaining) + } + + return String(format: "Mode: Low power idle (%.0fs)", configuration.idleUpdateInterval) + } + + private func rescheduleTimer(using configuration: AppConfiguration) { + let newConfiguration = makeTimerConfiguration(configuration: configuration) + guard newConfiguration != timerConfiguration else { return } + + refreshTimer?.setEventHandler {} + refreshTimer?.cancel() + + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now() + newConfiguration.interval, + repeating: newConfiguration.interval, + leeway: .milliseconds(max(100, Int(newConfiguration.leeway * 1000.0)))) + timer.setEventHandler { [weak self] in + self?.refreshReadings(reason: .timer) + } + timer.resume() + + refreshTimer = timer + timerConfiguration = newConfiguration + } + + private func makeTimerConfiguration(configuration: AppConfiguration) -> TimerConfiguration { + let interval: TimeInterval + if configuration.alwaysLiveUpdates { + interval = configuration.liveUpdateInterval + } else if let burstEndDate, burstEndDate > Date() { + interval = configuration.liveUpdateInterval + } else { + interval = configuration.idleUpdateInterval + } + + return TimerConfiguration(interval: interval, + leeway: max(0.2, interval * 0.2)) + } + + private func menuBarTitle(snapshot: PowerSnapshot) -> String { + guard let batteryWatts = snapshot.battery.watts else { + return String(format: "%dW", snapshot.adapter.watts) + } + + return String(format: "%dW | %@%.1fW", + snapshot.adapter.watts, + snapshot.battery.flowSymbol, + abs(batteryWatts)) + } + + private func adapterTitle(_ adapter: AdapterInfo) -> String { + if adapter.hasValidVoltsAndAmps { + return String(format: "Adapter: %d W, %.02f V, %.02f A", adapter.watts, adapter.volts, adapter.amps) + } + + if adapter.watts != 0 { + return String(format: "Adapter: %d W (%@)", adapter.watts, chargingText) + } + + return notChargingText + } + + private func batteryTitle(_ battery: BatteryInfo) -> String { + guard let watts = battery.watts, + let amps = battery.amps, + let volts = battery.volts else { + return batteryUnknownText + } + + let direction: String + if watts > 0.05 { + direction = "Charging" + } else if watts < -0.05 { + direction = "Discharging" + } else { + direction = "Near idle" + } + + return String(format: "Battery: %@ %@%.1f W (%@%.02f A, %.02f V)", + battery.flowSymbol, + direction, + abs(watts), + amps >= 0 ? "+" : "-", + abs(amps), + volts) + } + + private func readPowerSnapshot() -> PowerSnapshot { + PowerSnapshot(adapter: readAdapterInfo(), battery: readBatteryInfo()) + } + + private func readAdapterInfo() -> AdapterInfo { + let unmanagedDict = IOPSCopyExternalPowerAdapterDetails() var watts = 0 var amps = 0.0 var volts = 0.0 var hasValidVoltsAndAmps = false - // Converts the CFDict into a Swift dict so we don't get nasty null pointer errors - // Thanks https://stackoverflow.com/questions/59458981/cfdictionarygetvalue-throws-exc-bad-access - if let dict = unmanagedDict?.takeRetainedValue() as? [String:Any]{ - // This might fail so it's all optional (e.g. watts isn't available when there's no charger present - if let maybeWatts = dict[kIOPSPowerAdapterWattsKey] as? Int{ + + if let dict = unmanagedDict?.takeRetainedValue() as? [String: Any] { + if let maybeWatts = dict[kIOPSPowerAdapterWattsKey] as? Int { watts = maybeWatts - // Try to also get amps, with which we can use to calculate volts - if let maybeAmps = dict[kIOPSPowerAdapterCurrentKey] as? Double{ + if let maybeAmps = dict[kIOPSPowerAdapterCurrentKey] as? Double { amps = maybeAmps / 1000.0 - if abs(amps) >= 1E-9{ + if abs(amps) >= 1E-9 { volts = Double(watts) / amps hasValidVoltsAndAmps = true } } } } - statusItem.button?.title=String(format:"%d W", watts) - if hasValidVoltsAndAmps{ - // Display full information if we have valid volts and amps readings - chargerDetail.title = String(format:"%.02f V, %.02f A", volts, amps) + + return AdapterInfo(watts: watts, + amps: amps, + volts: volts, + hasValidVoltsAndAmps: hasValidVoltsAndAmps) + } + + private func readBatteryInfo() -> BatteryInfo { + let batteryEntry = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("AppleSmartBattery")) + guard batteryEntry != IO_OBJECT_NULL else { + return BatteryInfo(watts: nil, + amps: nil, + volts: nil, + externalConnected: false, + isCharging: false) } - else{ - if watts != 0{ - // If we have watts but are somehow unable to get volts and/or amps, display a generic "I'm still charging" message - chargerDetail.title = STR_CHARGING_BUT_NO_AMPS_VOLTS - } - else{ - // If we don't have volts and amps, and watts is zero, then we're most likely not charging. - chargerDetail.title = STR_NOT_CHARGING - } + + defer { IOObjectRelease(batteryEntry) } + + var properties: Unmanaged? + let result = IORegistryEntryCreateCFProperties(batteryEntry, &properties, kCFAllocatorDefault, 0) + guard result == KERN_SUCCESS, + let dict = properties?.takeRetainedValue() as? [String: Any] else { + return BatteryInfo(watts: nil, + amps: nil, + volts: nil, + externalConnected: false, + isCharging: false) + } + + let amperageMilliAmps = signedIntegerValue(dict["Amperage"]) ?? signedIntegerValue(dict["InstantAmperage"]) + let voltageMilliVolts = signedIntegerValue(dict["Voltage"]) + let externalConnected = (dict["ExternalConnected"] as? Bool) ?? false + let isCharging = (dict["IsCharging"] as? Bool) ?? false + + guard let amperageMilliAmps, let voltageMilliVolts else { + return BatteryInfo(watts: nil, + amps: nil, + volts: nil, + externalConnected: externalConnected, + isCharging: isCharging) } + + let amps = Double(amperageMilliAmps) / 1000.0 + let volts = Double(voltageMilliVolts) / 1000.0 + let watts = amps * volts + + return BatteryInfo(watts: watts, + amps: amps, + volts: volts, + externalConnected: externalConnected, + isCharging: isCharging) } - - func applicationWillTerminate(_ notification: Notification) { + + private func signedIntegerValue(_ rawValue: Any?) -> Int? { + if let number = rawValue as? NSNumber { + return Int(Int64(bitPattern: number.uint64Value)) + } + if let value = rawValue as? Int { + return value + } + if let value = rawValue as? Int64 { + return Int(value) + } + if let value = rawValue as? UInt64 { + return Int(Int64(bitPattern: value)) + } + return nil } } +private final class PreferencesWindowController: NSWindowController { + private let defaults = UserDefaults.standard + private let onApply: () -> Void + + private let alwaysLiveCheckbox = NSButton(checkboxWithTitle: "Always live updates", target: nil, action: nil) + private let liveIntervalField = NSTextField(string: "") + private let burstDurationField = NSTextField(string: "") + private let idleIntervalField = NSTextField(string: "") + + init(onApply: @escaping () -> Void) { + self.onApply = onApply + + let contentRect = NSRect(x: 0, y: 0, width: 420, height: 250) + let window = NSWindow(contentRect: contentRect, + styleMask: [.titled, .closable], + backing: .buffered, + defer: false) + window.title = "WhatWatt Preferences" + window.isReleasedWhenClosed = false + + super.init(window: window) + window.contentView = makeContentView() + reloadFromDefaults() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func reloadFromDefaults() { + let configuration = AppConfiguration.current() + alwaysLiveCheckbox.state = configuration.alwaysLiveUpdates ? .on : .off + liveIntervalField.stringValue = formatNumber(configuration.liveUpdateInterval) + burstDurationField.stringValue = formatNumber(configuration.liveBurstDuration) + idleIntervalField.stringValue = formatNumber(configuration.idleUpdateInterval) + } + + private func makeContentView() -> NSView { + let numberFormatter = NumberFormatter() + numberFormatter.minimum = 1 + numberFormatter.maximumFractionDigits = 0 + numberFormatter.allowsFloats = false + + [liveIntervalField, burstDurationField, idleIntervalField].forEach { + $0.formatter = numberFormatter + $0.alignment = .right + } + + let helpLabel = NSTextField(labelWithString: "Default behavior is low power mode: refresh every 60 seconds, switch to 1-second updates for 20 seconds after a charger-state change.") + helpLabel.lineBreakMode = .byWordWrapping + helpLabel.maximumNumberOfLines = 3 + helpLabel.textColor = .secondaryLabelColor + + let grid = NSGridView(views: [ + [NSTextField(labelWithString: "Live interval (sec)"), liveIntervalField], + [NSTextField(labelWithString: "Burst duration (sec)"), burstDurationField], + [NSTextField(labelWithString: "Idle interval (sec)"), idleIntervalField], + ]) + grid.rowSpacing = 10 + grid.columnSpacing = 16 + grid.xPlacement = .fill + + let saveButton = NSButton(title: "Save", target: self, action: #selector(savePreferences(_:))) + let resetButton = NSButton(title: "Reset Defaults", target: self, action: #selector(resetDefaults(_:))) + let buttonRow = NSStackView(views: [resetButton, saveButton]) + buttonRow.orientation = .horizontal + buttonRow.spacing = 10 + buttonRow.alignment = .centerY + + let stack = NSStackView(views: [helpLabel, alwaysLiveCheckbox, grid, buttonRow]) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 14 + stack.edgeInsets = NSEdgeInsets(top: 18, left: 20, bottom: 18, right: 20) + + let container = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 250)) + container.addSubview(stack) + stack.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: container.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: container.trailingAnchor), + stack.topAnchor.constraint(equalTo: container.topAnchor), + stack.bottomAnchor.constraint(lessThanOrEqualTo: container.bottomAnchor), + liveIntervalField.widthAnchor.constraint(equalToConstant: 80), + burstDurationField.widthAnchor.constraint(equalToConstant: 80), + idleIntervalField.widthAnchor.constraint(equalToConstant: 80), + ]) + + return container + } + + @objc private func savePreferences(_ sender: Any?) { + defaults.set(alwaysLiveCheckbox.state == .on, forKey: DefaultsKey.alwaysLiveUpdates) + defaults.set(sanitizedValue(from: liveIntervalField, min: 1, max: 10, fallback: 1), forKey: DefaultsKey.liveUpdateInterval) + defaults.set(sanitizedValue(from: burstDurationField, min: 5, max: 300, fallback: 20), forKey: DefaultsKey.liveBurstDuration) + defaults.set(sanitizedValue(from: idleIntervalField, min: 10, max: 300, fallback: 60), forKey: DefaultsKey.idleUpdateInterval) + reloadFromDefaults() + onApply() + } + + @objc private func resetDefaults(_ sender: Any?) { + defaults.set(false, forKey: DefaultsKey.alwaysLiveUpdates) + defaults.set(1.0, forKey: DefaultsKey.liveUpdateInterval) + defaults.set(20.0, forKey: DefaultsKey.liveBurstDuration) + defaults.set(60.0, forKey: DefaultsKey.idleUpdateInterval) + reloadFromDefaults() + onApply() + } + + private func sanitizedValue(from field: NSTextField, min minValue: Double, max maxValue: Double, fallback: Double) -> Double { + guard let value = Double(field.stringValue), value.isFinite else { return fallback } + return Swift.min(maxValue, Swift.max(minValue, value)) + } + + private func formatNumber(_ value: Double) -> String { + String(Int(round(value))) + } +} From 3270d9372aa4910f9e1b148a2c69ee0f23c918ec Mon Sep 17 00:00:00 2001 From: Anish S <38997845+git-anish@users.noreply.github.com> Date: Sat, 14 Mar 2026 15:59:17 -0700 Subject: [PATCH 2/2] Add unplugged display preference --- README.md | 3 ++- WhatWatt/AppDelegate.swift | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4eeff97..184c7ee 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ This fork adds the behavior requested for day-to-day charger debugging: - burst mode: `1s` refresh for `20s` after a real charger-state change - menu toggle for `Always Live Updates` - lightweight `Preferences...` window for tuning live interval, burst duration, and idle interval -- cleaner menu bar title format such as `67W | ↓18.4W` +- cleaner menu bar title format such as `67W | ↑18.4W` while charging and `↓18.4W` when unplugged +- optional `Show 0W adapter when unplugged` preference if you prefer the explicit adapter state in the menu bar - Intel-safe signed current handling for wrapped battery amperage values ## Why this exists diff --git a/WhatWatt/AppDelegate.swift b/WhatWatt/AppDelegate.swift index 47fca99..4fc25b0 100644 --- a/WhatWatt/AppDelegate.swift +++ b/WhatWatt/AppDelegate.swift @@ -14,6 +14,7 @@ private enum DefaultsKey { static let liveUpdateInterval = "LiveUpdateInterval" static let liveBurstDuration = "LiveBurstDuration" static let idleUpdateInterval = "IdleUpdateInterval" + static let showAdapterWhenUnplugged = "ShowAdapterWhenUnplugged" } private struct AppConfiguration { @@ -21,6 +22,7 @@ private struct AppConfiguration { let liveUpdateInterval: TimeInterval let liveBurstDuration: TimeInterval let idleUpdateInterval: TimeInterval + let showAdapterWhenUnplugged: Bool static func current() -> AppConfiguration { let defaults = UserDefaults.standard @@ -28,7 +30,8 @@ private struct AppConfiguration { alwaysLiveUpdates: defaults.bool(forKey: DefaultsKey.alwaysLiveUpdates), liveUpdateInterval: clamp(defaults.double(forKey: DefaultsKey.liveUpdateInterval), min: 1.0, max: 10.0, fallback: 1.0), liveBurstDuration: clamp(defaults.double(forKey: DefaultsKey.liveBurstDuration), min: 5.0, max: 300.0, fallback: 20.0), - idleUpdateInterval: clamp(defaults.double(forKey: DefaultsKey.idleUpdateInterval), min: 10.0, max: 300.0, fallback: 60.0) + idleUpdateInterval: clamp(defaults.double(forKey: DefaultsKey.idleUpdateInterval), min: 10.0, max: 300.0, fallback: 60.0), + showAdapterWhenUnplugged: defaults.bool(forKey: DefaultsKey.showAdapterWhenUnplugged) ) } @@ -96,6 +99,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { private var batteryDetail: NSMenuItem! private var modeDetail: NSMenuItem! private var alwaysLiveMenuItem: NSMenuItem! + private var showAdapterWhenUnpluggedMenuItem: NSMenuItem! private var preferencesWindowController: PreferencesWindowController? private var powerSourceLoopSource: CFRunLoopSource? private var refreshTimer: DispatchSourceTimer? @@ -130,6 +134,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { DefaultsKey.liveUpdateInterval: 1.0, DefaultsKey.liveBurstDuration: 20.0, DefaultsKey.idleUpdateInterval: 60.0, + DefaultsKey.showAdapterWhenUnplugged: false, ]) } @@ -148,6 +153,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { alwaysLiveMenuItem = NSMenuItem(title: "Always Live Updates", action: #selector(toggleAlwaysLiveUpdates(_:)), keyEquivalent: "") alwaysLiveMenuItem.target = self + showAdapterWhenUnpluggedMenuItem = NSMenuItem(title: "Show 0W Adapter When Unplugged", action: #selector(toggleShowAdapterWhenUnplugged(_:)), keyEquivalent: "") + showAdapterWhenUnpluggedMenuItem.target = self + let preferencesItem = NSMenuItem(title: "Preferences...", action: #selector(openPreferences(_:)), keyEquivalent: ",") preferencesItem.target = self @@ -157,6 +165,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { menu.addItem(modeDetail) menu.addItem(NSMenuItem.separator()) menu.addItem(alwaysLiveMenuItem) + menu.addItem(showAdapterWhenUnpluggedMenuItem) menu.addItem(preferencesItem) menu.addItem(NSMenuItem.separator()) menu.addItem(withTitle: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") @@ -184,6 +193,12 @@ class AppDelegate: NSObject, NSApplicationDelegate { applyPreferences() } + @objc private func toggleShowAdapterWhenUnplugged(_ sender: NSMenuItem) { + let defaults = UserDefaults.standard + defaults.set(!defaults.bool(forKey: DefaultsKey.showAdapterWhenUnplugged), forKey: DefaultsKey.showAdapterWhenUnplugged) + applyPreferences() + } + @objc private func openPreferences(_ sender: Any?) { if preferencesWindowController == nil { preferencesWindowController = PreferencesWindowController { [weak self] in @@ -237,6 +252,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { private func syncMenuState() { let configuration = AppConfiguration.current() alwaysLiveMenuItem.state = configuration.alwaysLiveUpdates ? .on : .off + showAdapterWhenUnpluggedMenuItem.state = configuration.showAdapterWhenUnplugged ? .on : .off } private func currentModeLabel(configuration: AppConfiguration) -> String { @@ -287,10 +303,18 @@ class AppDelegate: NSObject, NSApplicationDelegate { } private func menuBarTitle(snapshot: PowerSnapshot) -> String { + let configuration = AppConfiguration.current() guard let batteryWatts = snapshot.battery.watts else { + if snapshot.adapter.watts == 0 && !configuration.showAdapterWhenUnplugged { + return snapshot.battery.flowSymbol + } return String(format: "%dW", snapshot.adapter.watts) } + if snapshot.adapter.watts == 0 && !configuration.showAdapterWhenUnplugged { + return String(format: "%@%.1fW", snapshot.battery.flowSymbol, abs(batteryWatts)) + } + return String(format: "%dW | %@%.1fW", snapshot.adapter.watts, snapshot.battery.flowSymbol, @@ -433,6 +457,7 @@ private final class PreferencesWindowController: NSWindowController { private let onApply: () -> Void private let alwaysLiveCheckbox = NSButton(checkboxWithTitle: "Always live updates", target: nil, action: nil) + private let showAdapterWhenUnpluggedCheckbox = NSButton(checkboxWithTitle: "Show 0W adapter when unplugged", target: nil, action: nil) private let liveIntervalField = NSTextField(string: "") private let burstDurationField = NSTextField(string: "") private let idleIntervalField = NSTextField(string: "") @@ -461,6 +486,7 @@ private final class PreferencesWindowController: NSWindowController { func reloadFromDefaults() { let configuration = AppConfiguration.current() alwaysLiveCheckbox.state = configuration.alwaysLiveUpdates ? .on : .off + showAdapterWhenUnpluggedCheckbox.state = configuration.showAdapterWhenUnplugged ? .on : .off liveIntervalField.stringValue = formatNumber(configuration.liveUpdateInterval) burstDurationField.stringValue = formatNumber(configuration.liveBurstDuration) idleIntervalField.stringValue = formatNumber(configuration.idleUpdateInterval) @@ -498,7 +524,7 @@ private final class PreferencesWindowController: NSWindowController { buttonRow.spacing = 10 buttonRow.alignment = .centerY - let stack = NSStackView(views: [helpLabel, alwaysLiveCheckbox, grid, buttonRow]) + let stack = NSStackView(views: [helpLabel, alwaysLiveCheckbox, showAdapterWhenUnpluggedCheckbox, grid, buttonRow]) stack.orientation = .vertical stack.alignment = .leading stack.spacing = 14 @@ -522,6 +548,7 @@ private final class PreferencesWindowController: NSWindowController { @objc private func savePreferences(_ sender: Any?) { defaults.set(alwaysLiveCheckbox.state == .on, forKey: DefaultsKey.alwaysLiveUpdates) + defaults.set(showAdapterWhenUnpluggedCheckbox.state == .on, forKey: DefaultsKey.showAdapterWhenUnplugged) defaults.set(sanitizedValue(from: liveIntervalField, min: 1, max: 10, fallback: 1), forKey: DefaultsKey.liveUpdateInterval) defaults.set(sanitizedValue(from: burstDurationField, min: 5, max: 300, fallback: 20), forKey: DefaultsKey.liveBurstDuration) defaults.set(sanitizedValue(from: idleIntervalField, min: 10, max: 300, fallback: 60), forKey: DefaultsKey.idleUpdateInterval) @@ -531,6 +558,7 @@ private final class PreferencesWindowController: NSWindowController { @objc private func resetDefaults(_ sender: Any?) { defaults.set(false, forKey: DefaultsKey.alwaysLiveUpdates) + defaults.set(false, forKey: DefaultsKey.showAdapterWhenUnplugged) defaults.set(1.0, forKey: DefaultsKey.liveUpdateInterval) defaults.set(20.0, forKey: DefaultsKey.liveBurstDuration) defaults.set(60.0, forKey: DefaultsKey.idleUpdateInterval)