Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Juice

A macOS menu bar app that shows what is eating your battery.
A macOS menu bar app that shows what is eating your battery and the current
compute power draw of a Mac mini.

![Swift 6](https://img.shields.io/badge/Swift-6-orange)
![macOS 14+](https://img.shields.io/badge/macOS-14%2B-blue)
Expand All @@ -24,11 +25,12 @@ Juice surfaces that data: which apps used how many watt-hours today, over the la
## Features

- **Menu bar live readout**: battery percent and live watts drawn (or charging wattage), updated continuously.
- **Mac mini server mode**: detects both legacy and current Mac mini models, then records combined and per-app CPU, GPU, and Neural Engine power every minute. Today, 1-week, and All views lead with the original battery-style app ranking, including live watts, accumulated Wh, real app icons, and clickable details. Server-wide average/peak watts, Wh/kWh, coverage, history, and projected 30-day energy remain underneath.
- **Top energy users**: per-app watt-hours for the current or last battery session, Today, 3 Days, Week, or All Time, with real app icons.
- **Per-app detail**: click any app to see where its energy went (CPU vs GPU vs Neural Engine), an hour-by-hour usage chart, and a plain-English explanation of the usage pattern.
- **Charge timeline**: battery level over the last 24 hours, sampled locally every minute, with on-AC periods highlighted.
- **Insights**: drain-rate anomalies measured against your own 7-day baseline, apps using far more than their typical energy, the energy hog of the week, and charging-habit observations.
- **Stats window**: the full app table (not just the top 8) plus a 7-day charge chart and battery health.
- **Stats window**: laptops get the full app table, 7-day charge chart, and battery health; Mac minis get a dedicated server dashboard with live app watts kept visible across Today, 1W, and All, permanent app energy totals, and system power history.
- **In-app updates**: choose automatic downloads and get notified when an update is ready to install, or keep updates manual and use “Check for Updates…” whenever you want. Homebrew installs update directly from Juice's signed release feed.
- **Honest charts**: axes are pinned to the real time window, recording gaps show as gaps, and partial data is labeled as such - the charts never stretch or interpolate data to look fuller than it is.
- **Private by default**: no telemetry, system profile, or accounts. Juice only contacts its release feed when you ask it to check for updates or enable automatic updates.
Expand Down Expand Up @@ -174,6 +176,8 @@ helper and app to share the same Team ID.
## How the numbers work

- Energy figures come from macOS's own per-coalition accounting in the powerlog database: CPU, GPU, and Neural Engine energy in nanojoules, converted to watt-hours (`Wh = nJ / 3.6e12`).
- On a Mac mini, Juice persists the combined live CPU/GPU/Neural Engine reading once per minute and integrates consecutive samples into Wh/kWh. Gaps longer than five minutes are excluded and shown as missing monitoring coverage instead of being estimated.
- Mac mini app rankings are integrated directly from the same live per-app energy accounting that drives the current-watts rows. Juice stores permanent hour-aligned app energy in the background, so Today, Week, All, and clickable app details do not depend on battery callbacks or PowerLog retention.
- A "coalition" is an app plus all its helper processes, which is why the numbers map to apps the way you would expect.
- macOS retains only about 3 days of this data; Juice's local store accumulates daily rollups indefinitely and keeps battery samples for 90 days, so your history grows beyond what the OS keeps.
- Rollup rebuilds only replace days the source data fully covers, so macOS purging its own retention window can never erase Juice's stored history.
Expand Down
8 changes: 8 additions & 0 deletions Scripts/build-app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,14 @@ else
chmod 755 "$APP_PATH/Contents/Library/HelperTools/JuiceHelper"
fi

# Verify the link-time framework path survived the optional universal merge
# before applying one fresh signature to the completed executable.
if ! otool -l "$APP_PATH/Contents/MacOS/Juice" \
| grep -Fq "path @executable_path/../Frameworks"; then
echo "Juice executable is missing its app-bundle framework rpath." >&2
exit 1
fi

/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$APP_PATH/Contents/Info.plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$APP_PATH/Contents/Info.plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $APP_BUNDLE_ID" "$APP_PATH/Contents/Info.plist"
Expand Down
70 changes: 61 additions & 9 deletions Sources/Juice/AppDetail/AppDetailPresenter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ import JuiceXPCShared
/// Mirrors ``StatsWindowPresenter``: a single window is reused across
/// invocations, with its content (and title) swapped to the requested app.
final class AppDetailPresenter {
private enum ServerHistoryError: LocalizedError {
case storeUnavailable

var errorDescription: String? {
"Server app history is unavailable because the local Juice store could not be opened."
}
}

static let shared = AppDetailPresenter()

private var window: NSWindow?
Expand All @@ -26,15 +34,25 @@ final class AppDetailPresenter {
// One captured window end anchors the interval query, the chart's
// x-domain, and the explanation's hour count.
let windowEnd = session?.end ?? Date()
let store = Self.usesStoredHistory(range: range, origin: origin)
let isServerHistory = origin == .server
let store = (isServerHistory
|| Self.usesStoredHistory(range: range, origin: origin))
? JuiceApp.sampler?.store : nil
let formatter = RollupBuilder.dayFormatter()
let earliestStoredStart = store
.flatMap { try? $0.earliestRollupDay() }
.flatMap { formatter.date(from: $0) }
let earliestStoredStart: Date?
if isServerHistory {
earliestStoredStart = store.flatMap { try? $0.earliestSystemAppEnergyDate() }
} else {
earliestStoredStart = store
.flatMap { try? $0.earliestRollupDay() }
.flatMap { formatter.date(from: $0) }
}
let windowStart = session?.start ?? Self.windowStart(
range: range, usesStoredHistory: store != nil,
earliestStoredStart: earliestStoredStart, now: windowEnd)
range: range,
usesStoredHistory: store != nil,
isServerHistory: isServerHistory,
earliestStoredStart: earliestStoredStart,
now: windowEnd)
let windowHours = max(1, Int((windowEnd.timeIntervalSince(windowStart) / 3600)
.rounded(.up)))
let storedSinceDay = store.map { _ in
Expand All @@ -46,13 +64,40 @@ final class AppDetailPresenter {
displayName: displayName,
bundleId: appKey,
rangeLabel: session.map(BatterySessionFormatting.title)
?? ((range == .week || range == .allTime) && store == nil
? "Available PowerLog history" : range.rawValue),
?? (isServerHistory
? range.rawValue
: ((range == .week || range == .allTime) && store == nil
? "Available PowerLog history" : range.rawValue)),
windowStart: windowStart,
windowEnd: windowEnd,
windowHours: windowHours,
resolution: store == nil ? .hourlyComponents : .dailyTotals,
resolution: isServerHistory
? .serverHourly
: (store == nil ? .hourlyComponents : .dailyTotals),
provider: {
if isServerHistory {
guard let store else {
throw ServerHistoryError.storeUnavailable
}
return try await Task.detached {
let buckets = try store.systemAppEnergyBuckets(
appKey: appKey,
since: windowStart,
until: windowEnd)
return AppEnergyBreakdown(
totalWh: buckets.reduce(0) { $0 + $1.energyWh },
cpuWh: 0,
gpuWh: 0,
aneWh: 0,
cpuHours: 0,
activeHours: buckets.reduce(0) {
$0 + $1.activeDuration / 3600
},
hourlyWh: buckets.map {
(bucketStart: $0.bucketStart, wh: $0.energyWh)
})
}.value
}
if let store {
return try await Task.detached {
let formatter = RollupBuilder.dayFormatter()
Expand Down Expand Up @@ -117,10 +162,17 @@ final class AppDetailPresenter {
static func windowStart(
range: EnergyRange,
usesStoredHistory: Bool,
isServerHistory: Bool = false,
earliestStoredStart: Date?,
now: Date,
calendar: Calendar = .current
) -> Date {
if isServerHistory {
return range.macMiniWindowStart(
now: now,
recordingSince: earliestStoredStart,
calendar: calendar)
}
if usesStoredHistory {
if range == .allTime {
return earliestStoredStart ?? now
Expand Down
59 changes: 52 additions & 7 deletions Sources/Juice/AppDetail/AppDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ struct AppDetailView: View {
enum Resolution {
case hourlyComponents
case dailyTotals
case serverHourly
}

let displayName: String
Expand Down Expand Up @@ -196,6 +197,8 @@ struct AppDetailView: View {
energyChart(breakdown)
if resolution == .dailyTotals {
historicalSummary(breakdown)
} else if resolution == .serverHourly {
serverSummary(breakdown)
} else {
explanation(breakdown)
}
Expand All @@ -218,7 +221,7 @@ struct AppDetailView: View {
Text(displayName)
.font(.title3.weight(.semibold))
.lineLimit(1)
Text("\(String(format: "%.1f Wh", breakdown.totalWh)) · \(rangeLabel)")
Text("\(detailEnergyText(breakdown.totalWh)) · \(rangeLabel)")
.font(.caption)
.foregroundStyle(.secondary)
}
Expand Down Expand Up @@ -312,7 +315,7 @@ struct AppDetailView: View {
AxisGridLine().foregroundStyle(Color.secondary.opacity(0.15))
AxisValueLabel {
if let wh = value.as(Double.self) {
Text(String(format: "%.1f Wh", wh))
Text(detailEnergyText(wh))
.font(.system(size: 8))
.foregroundStyle(.secondary)
}
Expand Down Expand Up @@ -501,6 +504,31 @@ struct AppDetailView: View {
}
}

private func serverSummary(_ breakdown: AppEnergyBreakdown) -> some View {
let activeDurationText = serverActiveDurationText(breakdown.activeHours)
let averageWattsText = liveWattsText(
breakdown.activeHours > 0
? breakdown.totalWh / breakdown.activeHours
: 0)
return VStack(alignment: .leading, spacing: 4) {
Text("Server activity")
.font(.caption)
.foregroundStyle(.secondary)
if breakdown.activeHours > 0 {
Text(
"Active for \(activeDurationText), "
+ "averaging \(averageWattsText) while active.")
.font(.callout)
} else {
Text("No measurable app energy was recorded in this range.")
.font(.callout)
}
Text("Integrated from live app energy readings and saved every minute.")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}

private func explanation(_ breakdown: AppEnergyBreakdown) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text("Why it used this much")
Expand All @@ -520,19 +548,36 @@ struct AppDetailView: View {
}

private func statLine(_ breakdown: AppEnergyBreakdown) -> some View {
HStack(spacing: 6) {
Text(String(format: "%.1f CPU-hours", breakdown.cpuHours))
let averageWatts = breakdown.activeHours > 0
? breakdown.totalWh / breakdown.activeHours
: 0
return HStack(spacing: 6) {
if resolution != .serverHourly {
Text(String(format: "%.1f CPU-hours", breakdown.cpuHours))
}
if breakdown.activeHours > 0 {
Text("·")
Text(String(format: "%.1f W average while active",
breakdown.totalWh / breakdown.activeHours))
if resolution != .serverHourly {
Text("·")
}
if resolution == .serverHourly {
Text("\(liveWattsText(averageWatts)) average while active")
} else {
Text(String(format: "%.1f W average while active",
averageWatts))
}
}
Spacer()
}
.font(.caption)
.foregroundStyle(.secondary)
.monospacedDigit()
}

private func detailEnergyText(_ wattHours: Double) -> String {
resolution == .serverHourly
? serverEnergyText(wattHours)
: String(format: "%.1f Wh", wattHours)
}
}

/// The app's real icon when the bundle id resolves, otherwise a lettered
Expand Down
16 changes: 15 additions & 1 deletion Sources/Juice/BatteryViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,28 @@ final class BatteryViewModel: ObservableObject {
@Published var reading: BatteryReading?
@Published var lastError: String?
@Published private(set) var isLowPowerModeEnabled: Bool
let isMacMini: Bool

/// Invoked after each successful refresh with the fresh reading.
var onReading: ((BatteryReading) -> Void)?

private var timer: AnyCancellable?
private var powerStateObserver: AnyCancellable?
private let lowPowerModeProvider: () -> Bool
private let batteryReader: () throws -> BatteryReading

init(
onReading: ((BatteryReading) -> Void)? = nil,
isMacMini: Bool = MacHardware.isCurrentMacMini,
batteryReader: @escaping () throws -> BatteryReading = BatteryMonitor.read,
lowPowerModeProvider: @escaping () -> Bool = {
ProcessInfo.processInfo.isLowPowerModeEnabled
},
notificationCenter: NotificationCenter = .default
) {
self.onReading = onReading
self.isMacMini = isMacMini
self.batteryReader = batteryReader
self.lowPowerModeProvider = lowPowerModeProvider
isLowPowerModeEnabled = lowPowerModeProvider()
refresh()
Expand All @@ -42,8 +48,16 @@ final class BatteryViewModel: ObservableObject {
}

func refresh() {
// A Mac mini intentionally has no battery. Its current-power reading is
// supplied by LivePowerCoordinator, so absence of AppleSmartBattery is
// a supported mode rather than an error.
guard !isMacMini else {
reading = nil
lastError = nil
return
}
do {
let fresh = try BatteryMonitor.read()
let fresh = try batteryReader()
reading = fresh
lastError = nil
onReading?(fresh)
Expand Down
2 changes: 2 additions & 0 deletions Sources/Juice/EnergySourceSelector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ enum DataOrigin {
case loading
/// The app's own rollup store, which accumulates history indefinitely.
case store
/// Direct one-minute app attribution recorded on a Mac mini.
case server
/// The live powerlog database, which macOS only retains for about 3 days.
case live
/// The live source failed, so no per-app data is available.
Expand Down
15 changes: 15 additions & 0 deletions Sources/Juice/HelperRegistrationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ extension SMAppService: HelperServiceManaging {}
final class HelperRegistrationController: NSObject, ObservableObject {
static let shared = HelperRegistrationController()

#if DEV_HELPER
@Published private(set) var state: HelperRegistrationState = .enabled
#else
@Published private(set) var state: HelperRegistrationState = .checking
#endif
/// Advances only when a user/recovery transition makes the helper newly
/// usable, so views can retry data without looping on ordinary error checks.
@Published private(set) var readyGeneration = 0
Expand Down Expand Up @@ -72,16 +76,22 @@ final class HelperRegistrationController: NSObject, ObservableObject {
self.sleep = sleep ?? { duration in try? await Task.sleep(for: duration) }
self.unregisterTimeout = unregisterTimeout
super.init()
#if !DEV_HELPER
NotificationCenter.default.addObserver(
self,
selector: #selector(appDidBecomeActive),
name: NSApplication.didBecomeActiveNotification,
object: nil)
#endif
}

/// Called at app launch. First installs the service registration, then on
/// later app builds refreshes it as required by SMAppService.
func prepare() async {
#if DEV_HELPER
state = .enabled
return
#else
guard !isPreparing else { return }
let stateBeforePreparation = state
isPreparing = true
Expand Down Expand Up @@ -132,11 +142,16 @@ final class HelperRegistrationController: NSObject, ObservableObject {
@unknown default:
state = .failed("Unknown helper service status")
}
#endif
}

/// Re-read approval state after returning from System Settings.
func refresh() {
#if DEV_HELPER
state = .enabled
#else
Task { await refreshStatus() }
#endif
}

/// Async implementation exposed internally so lifecycle transitions can
Expand Down
Loading