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
40 changes: 40 additions & 0 deletions MacActivity.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions Sources/MacActivityApp/Localization/AppLocalization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,15 @@ enum AppLocalization {
case diskCleanupCategoryPlural = "diskCleanup.category.plural"
case energyImpactEmpty = "energyImpact.empty"
case energyImpactUnavailable = "energyImpact.unavailable"
case energyImpactTitle = "energyImpact.title"
case energyImpactSubtitleCurrent = "energyImpact.subtitle.current"
case energyImpactAppColumn = "energyImpact.column.app"
case energyImpactCurrentColumn = "energyImpact.column.current"
case energyImpactCollecting = "energyImpact.collecting"
case energyImpactPartial = "energyImpact.partial"
case energyImpactStale = "energyImpact.stale"
case energyImpactStaleWithValue = "energyImpact.staleWithValue"
case energyImpactRowAccessibility = "energyImpact.accessibility.row"
case processEmpty = "process.empty"
case processFallbackName = "process.fallbackName"
case processActionRequested = "process.action.requested"
Expand Down
202 changes: 194 additions & 8 deletions Sources/MacActivityApp/Models/EnergyImpactModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,26 +16,46 @@ final class EnergyImpactModel: ObservableObject {

private let provider: any EnergyImpactProviding
private let limit: Int
private let samplingDelayNanoseconds: UInt64
private let initialWindowNanoseconds: UInt64
private let clock: any EnergyImpactClock
private let sleep: (UInt64) async throws -> Void
private let smoothingOverrideForTesting: ((
EnergyImpactProcessIdentity,
Double,
TimeInterval
) -> Double?)?
private let configuration = EnergyImpactConfiguration.production

private var smoother = EnergyImpactSmoother(
halfLifeSeconds: EnergyImpactConfiguration.production.fastHalfLifeSeconds
)
private var ranker = StableEnergyImpactRanker()
private var lastValidObservationTimes: [EnergyImpactProcessIdentity: TimeInterval] = [:]
private var lastPublicationTime: TimeInterval?

init(
provider: any EnergyImpactProviding = EnergyImpactService(),
limit: Int = 20,
samplingDelayNanoseconds: UInt64 = 250_000_000,
sleep: @escaping (UInt64) async throws -> Void = { try await Task.sleep(nanoseconds: $0) }
initialWindowNanoseconds: UInt64 = 3_000_000_000,
clock: any EnergyImpactClock = SystemEnergyImpactClock(),
smoothingOverrideForTesting: ((EnergyImpactProcessIdentity, Double, TimeInterval) -> Double?)? = nil,
sleep: @escaping (UInt64) async throws -> Void = {
try await Task.sleep(nanoseconds: $0)
}
) {
self.provider = provider
self.limit = limit
self.samplingDelayNanoseconds = samplingDelayNanoseconds
self.initialWindowNanoseconds = initialWindowNanoseconds
self.clock = clock
self.sleep = sleep
self.smoothingOverrideForTesting = smoothingOverrideForTesting
}

func refresh() async {
isRefreshing = true
_ = provider.topApps(limit: limit)
_ = provider.topApps(limit: .max)
do {
try await sleep(samplingDelayNanoseconds)
try await sleep(initialWindowNanoseconds)
} catch {
isRefreshing = false
return
Expand All @@ -44,7 +64,7 @@ final class EnergyImpactModel: ObservableObject {
isRefreshing = false
return
}
entries = provider.topApps(limit: limit)
publish(provider.topApps(limit: .max), at: clock.nowSeconds())
isRefreshing = false
}

Expand All @@ -57,7 +77,173 @@ final class EnergyImpactModel: ObservableObject {
return
}
guard Task.isCancelled == false else { return }
entries = provider.topApps(limit: limit)
publish(provider.topApps(limit: .max), at: clock.nowSeconds())
}
}

private func publish(_ candidates: [EnergyImpactEntry], at publicationTime: TimeInterval) {
guard publicationTime.isFinite else {
resetStatistics()
entries = Array(
ranker.rank(
candidates.map(Self.sanitizedForInvalidClock),
atPublicationBoundary: true
)
.prefix(max(0, limit))
)
return
}

if let lastPublicationTime {
let publicationGap = publicationTime - lastPublicationTime
if publicationGap <= 0 || publicationGap > configuration.maximumGapSeconds {
resetStatistics()
}
}

let currentGenerations = Set(candidates.compactMap(\.identity.generation))
smoother.retainOnly(currentGenerations)
lastValidObservationTimes = lastValidObservationTimes.filter {
currentGenerations.contains($0.key)
}

let processed = candidates.map { candidate in
process(candidate, at: publicationTime)
}
lastPublicationTime = publicationTime

entries = Array(
ranker.rank(processed, atPublicationBoundary: true)
.prefix(max(0, limit))
)
}

private func process(
_ candidate: EnergyImpactEntry,
at publicationTime: TimeInterval
) -> EnergyImpactEntry {
let sanitized = Self.sanitizingInvalidNumerics(candidate)
guard sanitized.status == .stable || sanitized.status == .partial else {
return sanitized
}
guard let currentPower = sanitized.currentPowerMicrowatts,
let rankingScore = sanitized.rankingScore,
currentPower.isFinite,
currentPower >= 0,
rankingScore.isFinite,
rankingScore >= 0 else {
return Self.nonnumericUnavailable(sanitized)
}
guard let generation = sanitized.identity.generation else {
return sanitized
}

let elapsedSeconds: TimeInterval
if let lastValidObservationTime = lastValidObservationTimes[generation] {
elapsedSeconds = publicationTime - lastValidObservationTime
} else {
elapsedSeconds = configuration.publicationIntervalSeconds
}
guard elapsedSeconds.isFinite, elapsedSeconds > 0 else {
return Self.nonnumericUnavailable(sanitized)
}

if elapsedSeconds > configuration.maximumGapSeconds {
smoother.retainOnly(
Set(lastValidObservationTimes.keys).subtracting([generation])
)
lastValidObservationTimes[generation] = nil
}
let smoothingElapsed = min(elapsedSeconds, configuration.maximumGapSeconds)
let smoothed: Double?
if let smoothingOverrideForTesting {
smoothed = smoothingOverrideForTesting(
generation,
currentPower,
smoothingElapsed
)
} else {
smoothed = smoother.update(
identity: generation,
value: currentPower,
elapsedSeconds: smoothingElapsed
)
}
guard let smoothed else {
return Self.nonnumericUnavailable(sanitized)
}
lastValidObservationTimes[generation] = publicationTime
return Self.replacingCurrentPower(in: sanitized, with: smoothed)
}

private func resetStatistics() {
smoother = EnergyImpactSmoother(halfLifeSeconds: configuration.fastHalfLifeSeconds)
ranker.reset()
lastValidObservationTimes.removeAll()
lastPublicationTime = nil
}

private static func replacingCurrentPower(
in entry: EnergyImpactEntry,
with currentPower: Double
) -> EnergyImpactEntry {
EnergyImpactEntry(
identity: entry.identity,
name: entry.name,
bundleIdentifier: entry.bundleIdentifier,
bundleURL: entry.bundleURL,
kind: entry.kind,
currentPowerMicrowatts: currentPower,
sustainedPowerMicrowatts: entry.sustainedPowerMicrowatts,
rankingScore: currentPower,
trend: entry.trend,
coverage: entry.coverage,
status: entry.status
)
}

private static func nonnumericUnavailable(_ entry: EnergyImpactEntry) -> EnergyImpactEntry {
nonnumeric(entry, status: .unavailable)
}

private static func sanitizedForInvalidClock(_ entry: EnergyImpactEntry) -> EnergyImpactEntry {
let sanitized = sanitizingInvalidNumerics(entry)
if sanitized.status == .stable || sanitized.status == .partial {
return nonnumericUnavailable(sanitized)
}
return sanitized
}

private static func sanitizingInvalidNumerics(_ entry: EnergyImpactEntry) -> EnergyImpactEntry {
let numericValues = [
entry.currentPowerMicrowatts,
entry.sustainedPowerMicrowatts,
entry.rankingScore,
].compactMap { $0 }
guard numericValues.contains(where: { $0.isFinite == false || $0 < 0 }) else {
return entry
}
let status: EnergyImpactStatus =
entry.status == .stable || entry.status == .partial ? .unavailable : entry.status
return nonnumeric(entry, status: status)
}

private static func nonnumeric(
_ entry: EnergyImpactEntry,
status: EnergyImpactStatus
) -> EnergyImpactEntry {
EnergyImpactEntry(
identity: entry.identity,
name: entry.name,
bundleIdentifier: entry.bundleIdentifier,
bundleURL: entry.bundleURL,
kind: entry.kind,
currentPowerMicrowatts: nil,
sustainedPowerMicrowatts: nil,
rankingScore: nil,
trend: entry.trend,
coverage: entry.coverage,
status: status
)
}
}
70 changes: 70 additions & 0 deletions Sources/MacActivityApp/Models/EnergyImpactPresentation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import Foundation
import MacActivityCore

enum EnergyImpactPresentation {
static func powerText(microwatts: Double, locale: Locale) -> String {
guard microwatts.isFinite, microwatts >= 0 else { return "—" }
if microwatts >= 1_000 {
let value = (microwatts / 1_000).formatted(
.number.locale(locale).precision(.fractionLength(0...1))
)
return "\(value) mW"
}
let value = microwatts.formatted(
.number.locale(locale).precision(.fractionLength(0...1))
)
return "\(value) µW"
}

static func powerText(
microwatts: Double?,
status: EnergyImpactStatus,
bundle: Bundle? = nil
) -> String {
if status == .stale {
guard let microwatts, microwatts.isFinite, microwatts >= 0 else {
return AppLocalization.string(.energyImpactStale, bundle: bundle)
}
return AppLocalization.string(
.energyImpactStaleWithValue,
powerText(
microwatts: microwatts,
locale: AppLocalization.currentLocale(bundle: bundle)
),
bundle: bundle
)
}
guard let microwatts, microwatts.isFinite, microwatts >= 0 else {
Comment thread
bigtomcat6 marked this conversation as resolved.
let key: AppLocalization.Key = switch status {
case .collecting: .energyImpactCollecting
case .partial: .energyImpactPartial
case .stale: .energyImpactStale
case .stable, .unavailable: .energyImpactUnavailable
}
return AppLocalization.string(key, bundle: bundle)
}
return powerText(
microwatts: microwatts,
locale: AppLocalization.currentLocale(bundle: bundle)
)
}

static func accessibilityLabel(
entry: EnergyImpactEntry,
rank: Int,
bundle: Bundle? = nil
) -> String {
let value = powerText(
microwatts: entry.displayPowerMicrowatts,
status: entry.status,
bundle: bundle
)
return AppLocalization.string(
.energyImpactRowAccessibility,
entry.name,
rank,
value,
bundle: bundle
)
}
}
13 changes: 11 additions & 2 deletions Sources/MacActivityApp/Resources/de.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@
"preferences.temperatureHelp" = "Steuert die Temperaturmetrik in Statusleiste und Dashboard.";
"preferences.hardwareBatteryPercentage" = "Hardware-Akku-Prozentsatz anzeigen";
"preferences.hardwareBatteryPercentageHelp" = "Verwendet die Rohkapazität von AppleSmartBattery, falls verfügbar; sonst wird auf den Systemprozentsatz zurückgegriffen.";
"preferences.processApplicationIdentifier" = "Anwendungs-ID in der aktiven Prozessliste anzeigen";
"preferences.processApplicationIdentifier" = "App-ID in Prozesslisten anzeigen";
"preferences.diskCleanupScope" = "Bereinigungsumfang";
"preferences.diskCleanupHelp" = "Legt fest, welche Elemente die Actives-Datenträgerbereinigung scannt und löscht.";
"preferences.menuBarMetrics" = "Metriken der Menüleiste";
Expand All @@ -109,7 +109,16 @@
"diskCleanup.category.trash" = "Papierkorb";
"diskCleanup.category.userLogs" = "Protokolle";

"energyImpact.empty" = "Keine Vordergrund-Apps melden Energieauswirkungen.";
"energyImpact.title" = "Energieeinfluss";
"energyImpact.subtitle.current" = "Aktuelle CPU-Energieschätzung · Niedriger ist besser";
"energyImpact.column.app" = "App";
"energyImpact.column.current" = "Aktuell";
"energyImpact.collecting" = "Wird erfasst";
"energyImpact.partial" = "Teilweise";
"energyImpact.stale" = "Veraltet";
"energyImpact.staleWithValue" = "Veraltet · %1$@";
"energyImpact.accessibility.row" = "%1$@, Rang %2$lld, %3$@";
"energyImpact.empty" = "Keine regulären Apps melden eine Energieschätzung.";
"energyImpact.unavailable" = "Nicht verfügbar";

"memoryRelease.action.release" = "Freigeben";
Expand Down
13 changes: 11 additions & 2 deletions Sources/MacActivityApp/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@
"preferences.temperatureHelp" = "Controls the Temperature metric in the status bar and dashboard.";
"preferences.hardwareBatteryPercentage" = "Show hardware battery percentage";
"preferences.hardwareBatteryPercentageHelp" = "Uses raw AppleSmartBattery capacity when available; falls back to the system percentage.";
"preferences.processApplicationIdentifier" = "Show application ID in Actives process list";
"preferences.processApplicationIdentifier" = "Show application ID in process lists";
"preferences.diskCleanupScope" = "Cleanup scope";
"preferences.diskCleanupHelp" = "Controls what Actives Disk Cleanup scans and deletes.";
"preferences.menuBarMetrics" = "Menu bar metrics";
Expand Down Expand Up @@ -170,7 +170,16 @@
"diskCleanup.category.singular" = "category";
"diskCleanup.category.plural" = "categories";

"energyImpact.empty" = "No foreground apps are reporting energy impact.";
"energyImpact.title" = "Energy Impact";
"energyImpact.subtitle.current" = "Recent CPU energy estimate · Lower is better";
"energyImpact.column.app" = "App";
"energyImpact.column.current" = "Current";
"energyImpact.collecting" = "Collecting";
"energyImpact.partial" = "Partial";
"energyImpact.stale" = "Stale";
"energyImpact.staleWithValue" = "Stale · %1$@";
"energyImpact.accessibility.row" = "%1$@, rank %2$lld, %3$@";
"energyImpact.empty" = "No regular apps are reporting an energy estimate.";
"energyImpact.unavailable" = "Unavailable";

"process.empty" = "No foreground apps are reporting memory usage.";
Expand Down
Loading
Loading