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: 8 additions & 0 deletions MacActivity.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@
9860C042B6168A52097ECE16 /* ActiveCleanupModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2E7A42055FEA7CDD8EED194 /* ActiveCleanupModel.swift */; };
9A395BB79F2CCC0C7DCD4117 /* BatterySystemPowerSourceReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420BEFFE5B99B07B33CAF5DE /* BatterySystemPowerSourceReader.swift */; };
9AA5402E9D5A714CBB34311A /* MemoryProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B94D2FEA382B619F7773A1B4 /* MemoryProviderTests.swift */; };
A1C4B920CF9DA8C8F81F347F /* EnergyImpactPublicationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 443015F2FA5999B5DE29199C /* EnergyImpactPublicationState.swift */; };
A1DC0091AFAA3500C5AAA85F /* ActiveAppMemoryProviding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 716895D62F7DE80DDB7F6D63 /* ActiveAppMemoryProviding.swift */; };
A9BECB3CD32A259E10292422 /* DashboardPopoverControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0742EBD55AA887021B006D58 /* DashboardPopoverControllerTests.swift */; };
AD712FC4EBAA68F9349BACD3 /* AppPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF9A4149A89C1A5F8C4CB520 /* AppPreferences.swift */; };
Expand All @@ -129,6 +130,7 @@
C758FD3134C4FC4F259D6C12 /* MetricsStoreHistoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8428982312A6028D3AC20D1F /* MetricsStoreHistoryTests.swift */; };
C881AFF3738010EDBEC7AA4E /* ProcessTapVolumeEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21E4322AB6B59C2521EDE0BB /* ProcessTapVolumeEngineTests.swift */; };
C94D70721B9A9210B31F328D /* AudioProcessService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 783BAC8E92D1DF859D51D06B /* AudioProcessService.swift */; };
CA966D34C7D256937901291F /* EnergyImpactPublicationStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3CBD9E2D4FE41FA3CD30931 /* EnergyImpactPublicationStateTests.swift */; };
CC4C0545E209D780F12C21BE /* AudioSystemMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A4643019F12953DA4A2FBD2 /* AudioSystemMonitor.swift */; };
CC96AD1A721BD82A5462EF6A /* MemoryProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDC3E242AE71B79619755C0D /* MemoryProvider.swift */; };
CD615D0A7A12AB82049484F5 /* PreferencesStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4797F590CDBAA38A16A0E76D /* PreferencesStore.swift */; };
Expand Down Expand Up @@ -266,6 +268,7 @@
41333E534CBDB52CD351763E /* DashboardModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardModel.swift; sourceTree = "<group>"; };
420BEFFE5B99B07B33CAF5DE /* BatterySystemPowerSourceReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatterySystemPowerSourceReader.swift; sourceTree = "<group>"; };
422B3F924F7EC6DA0DF6493F /* NetworkProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProviderTests.swift; sourceTree = "<group>"; };
443015F2FA5999B5DE29199C /* EnergyImpactPublicationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnergyImpactPublicationState.swift; sourceTree = "<group>"; };
443C3EE222DFFEB15FA514D2 /* ActiveProcessMemoryRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveProcessMemoryRow.swift; sourceTree = "<group>"; };
46B63F02833B7A142E6D3C19 /* AppLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLanguage.swift; sourceTree = "<group>"; };
4797F590CDBAA38A16A0E76D /* PreferencesStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesStore.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -363,6 +366,7 @@
DAC3D5561158D1EC418C2BC9 /* AudioRoutingModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRoutingModels.swift; sourceTree = "<group>"; };
E03A2C158D947FE65167CEA8 /* EnergyImpactPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnergyImpactPresentation.swift; sourceTree = "<group>"; };
E2E54E53FD145EE2DACD72F6 /* AudioAggregateTopologyResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioAggregateTopologyResolver.swift; sourceTree = "<group>"; };
E3CBD9E2D4FE41FA3CD30931 /* EnergyImpactPublicationStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnergyImpactPublicationStateTests.swift; sourceTree = "<group>"; };
E49ECEEFF448A10DF483AD89 /* TemperatureProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureProvider.swift; sourceTree = "<group>"; };
E623687BEBC14372C97ABDDF /* AudioTapHardware.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioTapHardware.swift; sourceTree = "<group>"; };
E6B629DAB047D5C4066597A1 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -547,6 +551,7 @@
7244116397B5BA1B99ACDEBD /* EnergyImpactOwnership.swift */,
89A4859CDCAE97D91FDA5990 /* EnergyImpactProcessReader.swift */,
F6C57F502EC1B86CD070FE38 /* EnergyImpactProvider.swift */,
443015F2FA5999B5DE29199C /* EnergyImpactPublicationState.swift */,
9A1F7A521F369C3B108ACABF /* EnergyImpactSampler.swift */,
1C6E6EE79DD4E13EB9F43826 /* EnergyImpactStatistics.swift */,
90E95B0B56AA23B56343FB1C /* EnergyImpactTypes.swift */,
Expand Down Expand Up @@ -669,6 +674,7 @@
5BF7D4EC5F491FFCA175F513 /* EnergyImpactOwnershipTests.swift */,
B6E756788A431A050E50FDAA /* EnergyImpactProcessReaderTests.swift */,
4FBBF160184071B2E1059B58 /* EnergyImpactProviderTests.swift */,
E3CBD9E2D4FE41FA3CD30931 /* EnergyImpactPublicationStateTests.swift */,
76C6A386B2AFC5BB533B7BD4 /* EnergyImpactSamplerTests.swift */,
7F7B5639CB348DA6FDAE9279 /* EnergyImpactStatisticsTests.swift */,
B66490C62BF6EE5944B75C67 /* EnergyImpactTraceReplayTests.swift */,
Expand Down Expand Up @@ -967,6 +973,7 @@
B318222CE0D85B319EA3573F /* EnergyImpactOwnershipTests.swift in Sources */,
7A14AE60E94454E455406530 /* EnergyImpactProcessReaderTests.swift in Sources */,
EDC980EF30033CA70792C40E /* EnergyImpactProviderTests.swift in Sources */,
CA966D34C7D256937901291F /* EnergyImpactPublicationStateTests.swift in Sources */,
83D17CCB57BACDD3F3BBFF9E /* EnergyImpactSamplerTests.swift in Sources */,
7CA75E9DA5AE16CBF75521EB /* EnergyImpactStatisticsTests.swift in Sources */,
1B0012B1804C865A55F713DA /* EnergyImpactTraceReplayTests.swift in Sources */,
Expand Down Expand Up @@ -1024,6 +1031,7 @@
DE87E8B9F0BBFA1E45FF2961 /* EnergyImpactOwnership.swift in Sources */,
B4056ECF58614FBE94D6B1F1 /* EnergyImpactProcessReader.swift in Sources */,
0F997EFB2933D1DF01944138 /* EnergyImpactProvider.swift in Sources */,
A1C4B920CF9DA8C8F81F347F /* EnergyImpactPublicationState.swift in Sources */,
B6059BE6D5CE1387694F8BBD /* EnergyImpactSampler.swift in Sources */,
2F976168C40F4D4D3D54A629 /* EnergyImpactStatistics.swift in Sources */,
25F6AB66E8EE1DA07F240610 /* EnergyImpactTypes.swift in Sources */,
Expand Down
203 changes: 74 additions & 129 deletions Sources/MacActivityApp/Models/EnergyImpactModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ import MacActivityCore

@MainActor
protocol EnergyImpactProviding: AnyObject {
func beginSession() async -> EnergyImpactSessionID
func sample(
sessionID: EnergyImpactSessionID,
func beginSession() async -> EnergyImpactSamplingLease?

func observe(
lease: EnergyImpactSamplingLease,
limit: Int,
scope: EnergyImpactAppScope,
publicationBoundary: Bool
scope: EnergyImpactAppScope
) async -> [EnergyImpactEntry]?
func endSession(_ sessionID: EnergyImpactSessionID) async

func endSession(_ lease: EnergyImpactSamplingLease) async
}

extension EnergyImpactService: EnergyImpactProviding {}
Expand All @@ -23,165 +24,109 @@ final class EnergyImpactModel: ObservableObject {

private let provider: any EnergyImpactProviding
private let limit: Int
private let sampleIntervalNanoseconds: UInt64
private let publicationIntervalNanoseconds: UInt64
private let sleep: @MainActor (UInt64) async throws -> Void

private let observationIntervalNanoseconds: UInt64
private let nowNanoseconds: () -> UInt64
private let sleep: (UInt64) async throws -> Void
private var activeRunID: UUID?
private var activeSessionID: EnergyImpactSessionID?
private var providerRequestInFlight = false
private var providerRequestWaiters = [CheckedContinuation<Void, Never>]()

init(
provider: any EnergyImpactProviding = EnergyImpactService(),
limit: Int = 20,
sampleIntervalNanoseconds: UInt64 = 1_000_000_000,
publicationIntervalNanoseconds: UInt64 = 3_000_000_000,
sleep: @escaping @MainActor (UInt64) async throws -> Void = {
observationIntervalNanoseconds: UInt64 = 3_000_000_000,
nowNanoseconds: @escaping () -> UInt64 = {
DispatchTime.now().uptimeNanoseconds
},
sleep: @escaping (UInt64) async throws -> Void = {
try await Task.sleep(nanoseconds: $0)
}
) {
self.provider = provider
self.limit = limit
self.sampleIntervalNanoseconds = sampleIntervalNanoseconds
self.publicationIntervalNanoseconds = publicationIntervalNanoseconds
self.observationIntervalNanoseconds = observationIntervalNanoseconds
self.nowNanoseconds = nowNanoseconds
self.sleep = sleep
}

func refreshWhileVisible() async {
func refreshWhileVisible(
scope: EnergyImpactAppScope = .regularOnly
) async {
let runID = UUID()
activeRunID = runID
activeSessionID = nil
isRefreshing = true

guard Task.isCancelled == false else {
clearRunIfCurrent(runID)
finishRun(runID)
return
}
guard let sessionID = await beginProviderSession(for: runID),
isCurrent(runID),
Task.isCancelled == false else {
clearRunIfCurrent(runID)
guard let lease = await provider.beginSession() else {
finishRun(runID)
return
}
activeSessionID = sessionID

guard case let .sampled(initial) = await sampleProvider(
for: runID,
sessionID: sessionID,
publicationBoundary: false
) else {
await endProviderSessionIfCurrent(sessionID, for: runID)
clearRunIfCurrent(runID)
return
}

var latest = initial
var elapsedSincePublication: UInt64 = 0
var deadline = nowNanoseconds()
do {
while isCurrent(runID), Task.isCancelled == false {
try await sleep(sampleIntervalNanoseconds)
guard isCurrent(runID), Task.isCancelled == false else { break }
while Task.isCancelled == false, activeRunID == runID {
let now = nowNanoseconds()
if deadline > now {
try await sleep(deadline - now)
}
guard Task.isCancelled == false,
activeRunID == runID else {
break
}
guard let observed = await provider.observe(
lease: lease,
limit: limit,
scope: scope
) else {
break
}
guard Task.isCancelled == false,
activeRunID == runID else {
break
}

let nextElapsed = elapsedSincePublication.addingReportingOverflow(
sampleIntervalNanoseconds
entries = observed
isRefreshing = false
deadline = Self.firstFutureDeadline(
after: deadline,
now: nowNanoseconds(),
interval: observationIntervalNanoseconds
)
let willPublish = nextElapsed.overflow
|| nextElapsed.partialValue >= publicationIntervalNanoseconds
guard case let .sampled(sampled) = await sampleProvider(
for: runID,
sessionID: sessionID,
publicationBoundary: willPublish
) else { break }
latest = sampled
guard isCurrent(runID), Task.isCancelled == false else { break }

elapsedSincePublication = nextElapsed.partialValue
if willPublish {
entries = latest
isRefreshing = false
elapsedSincePublication = 0
}
}
} catch is CancellationError {
// Hiding the page normally cancels its view task.
// Normal hidden-page exit; cleanup below still runs.
} catch {
// Keep current rows visible. The next page appearance starts a fresh session.
}

await endProviderSessionIfCurrent(sessionID, for: runID)
clearRunIfCurrent(runID)
}

private func beginProviderSession(for runID: UUID) async -> EnergyImpactSessionID? {
await acquireProviderRequestGate()
defer { releaseProviderRequestGate() }
guard isCurrent(runID), Task.isCancelled == false else { return nil }
return await provider.beginSession()
}

private func sampleProvider(
for runID: UUID,
sessionID: EnergyImpactSessionID,
publicationBoundary: Bool
) async -> ProviderSampleResult {
await acquireProviderRequestGate()
defer { releaseProviderRequestGate() }
guard isCurrent(runID),
activeSessionID == sessionID,
Task.isCancelled == false else { return .stopped }
guard let sampled = await provider.sample(
sessionID: sessionID,
limit: limit,
scope: .regularOnly,
publicationBoundary: publicationBoundary
), isCurrent(runID), Task.isCancelled == false else {
return .stopped
}
return .sampled(sampled)
}

private func endProviderSessionIfCurrent(
_ sessionID: EnergyImpactSessionID,
for runID: UUID
) async {
await acquireProviderRequestGate()
defer { releaseProviderRequestGate() }
guard isCurrent(runID), activeSessionID == sessionID else { return }
await provider.endSession(sessionID)
}

private func acquireProviderRequestGate() async {
guard providerRequestInFlight else {
providerRequestInFlight = true
return
// Preserve the last honest rows; a future visible run retries.
}
await withCheckedContinuation { continuation in
providerRequestWaiters.append(continuation)
}
}

private func releaseProviderRequestGate() {
guard providerRequestWaiters.isEmpty == false else {
providerRequestInFlight = false
return
}
providerRequestWaiters.removeFirst().resume()
await provider.endSession(lease)
finishRun(runID)
}

private func isCurrent(_ runID: UUID) -> Bool {
activeRunID == runID
private static func firstFutureDeadline(
after previousDeadline: UInt64,
now: UInt64,
interval rawInterval: UInt64
) -> UInt64 {
let interval = max(1, rawInterval)
let (first, firstOverflow) =
previousDeadline.addingReportingOverflow(interval)
guard firstOverflow == false else { return .max }
guard first <= now else { return first }

let missed = (now - first) / interval + 1
let (jump, jumpOverflow) =
interval.multipliedReportingOverflow(by: missed)
guard jumpOverflow == false else { return .max }
let (advanced, advancedOverflow) =
first.addingReportingOverflow(jump)
return advancedOverflow ? .max : advanced
}

private func clearRunIfCurrent(_ runID: UUID) {
guard isCurrent(runID) else { return }
private func finishRun(_ runID: UUID) {
guard activeRunID == runID else { return }
activeRunID = nil
activeSessionID = nil
isRefreshing = false
}
}

private enum ProviderSampleResult {
case sampled([EnergyImpactEntry])
case stopped
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,25 @@
import Darwin
import Foundation

public struct ProcessEnergyReading: Equatable, Sendable {
public let energyNanojoules: UInt64
public let processStartAbsoluteTime: UInt64
public let userCPUTime: UInt64
public let systemCPUTime: UInt64

public init(
energyNanojoules: UInt64,
processStartAbsoluteTime: UInt64 = 0,
userCPUTime: UInt64 = 0,
systemCPUTime: UInt64 = 0
) {
self.energyNanojoules = energyNanojoules
self.processStartAbsoluteTime = processStartAbsoluteTime
self.userCPUTime = userCPUTime
self.systemCPUTime = systemCPUTime
}
}

public enum ProcessEnergyReadFailure: Equatable, Sendable {
case exited
case permissionDenied
Expand Down
Loading
Loading