@npaw/forge-react-native 9.7.2 → 9.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/build.gradle +1 -1
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/Info.plist +7 -7
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/Headers/forge_kernelFFI.h +60 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/libForgeKernelFFI.a +0 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/Headers/forge_kernelFFI.h +60 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/libForgeKernelFFI.a +0 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/Headers/forge_kernelFFI.h +60 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/libForgeKernelFFI.a +0 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/Headers/forge_kernelFFI.h +60 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/libForgeKernelFFI.a +0 -0
- package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernel.swift +415 -1
- package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernelFFI.h +60 -0
- package/ios/ForgeApple/Sources/NPAWForgeAVPlayerAdapter/AVPlayerAdapter.swift +22 -1
- package/ios/ForgeApple/Sources/NPAWForgeCore/DeviceInsightsCollector.swift +494 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeApple.swift +51 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeCapturePlan.swift +14 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeRuntime.swift +48 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/NPAWForge.swift +49 -1
- package/ios/ForgeApple/Sources/NPAWForgeCore/NativeUniFFIRuntime.swift +77 -0
- package/package.json +1 -1
|
@@ -443,7 +443,13 @@ public final class AVPlayerAdapter: PlayerAdapter {
|
|
|
443
443
|
private func handleRate(_ rate: Float) {
|
|
444
444
|
if rate > 0 {
|
|
445
445
|
// Direct rate manipulation is also a play intent (OLD plugin-ios
|
|
446
|
-
// fires start from rate observations)
|
|
446
|
+
// fires start from rate observations) - but only with an item to
|
|
447
|
+
// play. `play()` on an itemless player has nothing to start (the
|
|
448
|
+
// start is anchored when the item arrives: `handleItemTransition`
|
|
449
|
+
// re-reads the time control status), and a rate that survives
|
|
450
|
+
// `replaceCurrentItem(with: nil)` must not re-open the view the
|
|
451
|
+
// item transition has just stopped.
|
|
452
|
+
guard player?.currentItem != nil else { return }
|
|
447
453
|
ensureStarted()
|
|
448
454
|
return
|
|
449
455
|
}
|
|
@@ -472,6 +478,21 @@ public final class AVPlayerAdapter: PlayerAdapter {
|
|
|
472
478
|
_ = firePause(metadata: snapshotMetadata())
|
|
473
479
|
}
|
|
474
480
|
case .waitingToPlayAtSpecifiedRate:
|
|
481
|
+
// A player that is only waiting because it has NO ITEM
|
|
482
|
+
// (`AVPlayer.WaitingReason.noItemToPlay`: `replaceCurrentItem(with:
|
|
483
|
+
// nil)` while `rate` is still non-zero) has nothing to start. That
|
|
484
|
+
// is exactly what react-native-video 7 does on teardown, right after
|
|
485
|
+
// the item transition above stopped the view: treating it as play
|
|
486
|
+
// intent re-opened the view the adapter had just stopped, and the
|
|
487
|
+
// `start` then sat in the metadata gate until the timeout flushed a
|
|
488
|
+
// phantom start with no item behind it (2026-08-16, iOS simulator).
|
|
489
|
+
// Any app that removes the item mid-playback reproduces it. The
|
|
490
|
+
// item check is deliberate: an item that has just been set is a
|
|
491
|
+
// real play intent even if AVFoundation still reports the stale
|
|
492
|
+
// no-item reason for it (`play()` before `replaceCurrentItem`).
|
|
493
|
+
if let player, player.reasonForWaitingToPlay == .noItemToPlay, player.currentItem == nil {
|
|
494
|
+
return
|
|
495
|
+
}
|
|
475
496
|
// Waiting-to-play is the earliest observable play intent on a cold
|
|
476
497
|
// start; anchoring `/start` here keeps the kernel-computed join
|
|
477
498
|
// time spanning the real startup buffering (OLD plugin-ios join
|
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (C) NPAW - All Rights Reserved
|
|
3
|
+
*
|
|
4
|
+
* This source code is protected under international copyright law. All rights
|
|
5
|
+
* reserved and protected by the copyright holders.
|
|
6
|
+
* This file is confidential and only available to authorized individuals with the
|
|
7
|
+
* permission of the copyright holders. If you encounter this file and do not have
|
|
8
|
+
* permission, please contact the copyright holders and delete this file.
|
|
9
|
+
*
|
|
10
|
+
*
|
|
11
|
+
* @author: Victor Soto <victor.soto@nicepeopleatwork.com>
|
|
12
|
+
* @description: Apple device insights collector - answers the kernel's periodic device sample requests with process CPU time, memory footprint and headroom, battery (iOS only) and thermal state, reading only the families the operator allowed and never a required-reason API.
|
|
13
|
+
*/
|
|
14
|
+
import Foundation
|
|
15
|
+
|
|
16
|
+
#if canImport(UIKit)
|
|
17
|
+
import UIKit
|
|
18
|
+
#endif
|
|
19
|
+
|
|
20
|
+
/// Thermal pressure on the kernel's normalised ordinal scale (`dev_thermal`).
|
|
21
|
+
///
|
|
22
|
+
/// The raw values are the wire ordinals shared with Android, so a Suite
|
|
23
|
+
/// filter on `dev_thermal=2` means "serious" on every platform.
|
|
24
|
+
public enum ForgeThermalState: Int, Equatable, CaseIterable {
|
|
25
|
+
case nominal = 0
|
|
26
|
+
case fair = 1
|
|
27
|
+
case serious = 2
|
|
28
|
+
case critical = 3
|
|
29
|
+
/// The Android-only top of the scale (`THERMAL_STATUS_CRITICAL` and
|
|
30
|
+
/// above). `ProcessInfo.ThermalState` stops at `.critical`, so Forge on
|
|
31
|
+
/// Apple never produces it.
|
|
32
|
+
case emergency = 4
|
|
33
|
+
|
|
34
|
+
/// Maps `ProcessInfo.ThermalState`. A state Forge does not recognise maps
|
|
35
|
+
/// to `nil` - the metric is omitted rather than guessed. Note the API
|
|
36
|
+
/// itself reports `.nominal` where thermal state is unsupported (the
|
|
37
|
+
/// simulator, some hardware); Forge forwards that as the platform states it.
|
|
38
|
+
public static func from(_ state: ProcessInfo.ThermalState) -> ForgeThermalState? {
|
|
39
|
+
switch state {
|
|
40
|
+
case .nominal: return .nominal
|
|
41
|
+
case .fair: return .fair
|
|
42
|
+
case .serious: return .serious
|
|
43
|
+
case .critical: return .critical
|
|
44
|
+
@unknown default: return nil
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// One device runtime health sample as forwarded to the kernel.
|
|
50
|
+
///
|
|
51
|
+
/// Every metric field is optional and independently absent: what the platform
|
|
52
|
+
/// cannot read is left `nil`, and the kernel then omits the metric instead of
|
|
53
|
+
/// inventing `0`. The kernel validates field-wise (a battery level above 100,
|
|
54
|
+
/// zero cores or a CPU counter that went backwards is dropped, the rest kept),
|
|
55
|
+
/// derives `dev_cpu` between two consecutive samples, and attaches the set to
|
|
56
|
+
/// the next session beat and video ping.
|
|
57
|
+
public struct ForgeDeviceSample: Equatable {
|
|
58
|
+
/// Wall clock at read time, ms since the Unix epoch. `0` lets the bridge
|
|
59
|
+
/// stamp the call with its own clock.
|
|
60
|
+
public var sampledAtMs: Int64
|
|
61
|
+
/// Cumulative process CPU time, user + system, ms, monotonic over the
|
|
62
|
+
/// process lifetime. Must travel together with ``cpuCores`` for `dev_cpu`.
|
|
63
|
+
public var cpuTimeMs: UInt64?
|
|
64
|
+
/// Logical cores available to the process, `>= 1`.
|
|
65
|
+
public var cpuCores: UInt32?
|
|
66
|
+
/// Process memory footprint (`phys_footprint`), bytes.
|
|
67
|
+
public var memUsedBytes: UInt64?
|
|
68
|
+
/// Memory this process may still allocate before hitting its limit, bytes.
|
|
69
|
+
public var memAvailBytes: UInt64?
|
|
70
|
+
/// Battery level `0...100`.
|
|
71
|
+
public var batteryLevelPct: UInt8?
|
|
72
|
+
/// Whether the device is charging (plugged in, charging or full).
|
|
73
|
+
public var batteryCharging: Bool?
|
|
74
|
+
public var thermal: ForgeThermalState?
|
|
75
|
+
/// Free storage on the app's volume, bytes. **Never filled by Forge on
|
|
76
|
+
/// Apple**: every free-disk API (`volumeAvailableCapacity*`, `statfs`,
|
|
77
|
+
/// `systemFreeSize`, ...) is an Apple required-reason API and Forge ships
|
|
78
|
+
/// no privacy manifest. The field exists so a host application that does
|
|
79
|
+
/// declare the reason can supply the value itself through
|
|
80
|
+
/// `NPAWForge.recordDeviceSample(_:)`.
|
|
81
|
+
public var diskFreeBytes: UInt64?
|
|
82
|
+
|
|
83
|
+
public init(
|
|
84
|
+
sampledAtMs: Int64 = 0,
|
|
85
|
+
cpuTimeMs: UInt64? = nil,
|
|
86
|
+
cpuCores: UInt32? = nil,
|
|
87
|
+
memUsedBytes: UInt64? = nil,
|
|
88
|
+
memAvailBytes: UInt64? = nil,
|
|
89
|
+
batteryLevelPct: UInt8? = nil,
|
|
90
|
+
batteryCharging: Bool? = nil,
|
|
91
|
+
thermal: ForgeThermalState? = nil,
|
|
92
|
+
diskFreeBytes: UInt64? = nil
|
|
93
|
+
) {
|
|
94
|
+
self.sampledAtMs = sampledAtMs
|
|
95
|
+
self.cpuTimeMs = cpuTimeMs
|
|
96
|
+
self.cpuCores = cpuCores
|
|
97
|
+
self.memUsedBytes = memUsedBytes
|
|
98
|
+
self.memAvailBytes = memAvailBytes
|
|
99
|
+
self.batteryLevelPct = batteryLevelPct
|
|
100
|
+
self.batteryCharging = batteryCharging
|
|
101
|
+
self.thermal = thermal
|
|
102
|
+
self.diskFreeBytes = diskFreeBytes
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/// `true` when no metric field is present (only the timestamp).
|
|
106
|
+
public var isEmpty: Bool {
|
|
107
|
+
cpuTimeMs == nil && cpuCores == nil
|
|
108
|
+
&& memUsedBytes == nil && memAvailBytes == nil
|
|
109
|
+
&& batteryLevelPct == nil && batteryCharging == nil
|
|
110
|
+
&& thermal == nil && diskFreeBytes == nil
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/// The metric families an operator can allow (`di.m`), spelled as the wire
|
|
115
|
+
/// tokens the kernel puts in a sample request.
|
|
116
|
+
public enum ForgeDeviceMetricFamily: String, CaseIterable, Equatable {
|
|
117
|
+
case cpu
|
|
118
|
+
case mem
|
|
119
|
+
case bat
|
|
120
|
+
case thm
|
|
121
|
+
case disk
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/// A battery reading exactly as the platform reports it, before shaping.
|
|
125
|
+
struct ForgeBatteryReading: Equatable {
|
|
126
|
+
enum State: Equatable {
|
|
127
|
+
case unknown
|
|
128
|
+
case unplugged
|
|
129
|
+
case charging
|
|
130
|
+
case full
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/// `UIDevice.batteryLevel`: `0...1`, negative when unknown.
|
|
134
|
+
var level: Float
|
|
135
|
+
var state: State
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/// Arms and disarms the platform's battery monitoring, which is what makes
|
|
139
|
+
/// `UIDevice.batteryLevel`/`batteryState` report anything at all.
|
|
140
|
+
struct ForgeBatteryMonitoringControl {
|
|
141
|
+
var isEnabled: () -> Bool
|
|
142
|
+
var setEnabled: (Bool) -> Void
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/// The platform reads the collector needs, injectable so the sample shaping is
|
|
146
|
+
/// testable on the host Mac and so a test can prove which reads happen.
|
|
147
|
+
///
|
|
148
|
+
/// `battery` and `batteryMonitoring` are `nil` where the platform has no
|
|
149
|
+
/// battery API: tvOS (`UIDevice`'s battery members are `API_UNAVAILABLE(tvos)`)
|
|
150
|
+
/// and macOS.
|
|
151
|
+
struct ForgeDeviceInsightsReaders {
|
|
152
|
+
var nowMs: () -> Int64
|
|
153
|
+
var cpuTimeMs: () -> UInt64?
|
|
154
|
+
var cpuCores: () -> UInt32?
|
|
155
|
+
var memUsedBytes: () -> UInt64?
|
|
156
|
+
var memAvailBytes: () -> UInt64?
|
|
157
|
+
var thermal: () -> ForgeThermalState?
|
|
158
|
+
var battery: (() -> ForgeBatteryReading?)?
|
|
159
|
+
var batteryMonitoring: ForgeBatteryMonitoringControl?
|
|
160
|
+
|
|
161
|
+
/// The real reads. Each one is either a plain Darwin/Foundation call or a
|
|
162
|
+
/// UIKit read, and none is on Apple's required-reason API list (file
|
|
163
|
+
/// timestamps, system boot time, disk space, active keyboards, user
|
|
164
|
+
/// defaults) - which is why Forge can collect these without shipping a
|
|
165
|
+
/// privacy manifest. In particular the timestamp comes from `Date()`, not
|
|
166
|
+
/// `systemUptime`/`mach_absolute_time`, and no free-disk API is called.
|
|
167
|
+
static let platform = ForgeDeviceInsightsReaders(
|
|
168
|
+
nowMs: { Int64(Date().timeIntervalSince1970 * 1000) },
|
|
169
|
+
cpuTimeMs: ForgeDeviceInsightsPlatform.processCpuTimeMs,
|
|
170
|
+
cpuCores: ForgeDeviceInsightsPlatform.activeProcessorCount,
|
|
171
|
+
memUsedBytes: ForgeDeviceInsightsPlatform.physicalFootprintBytes,
|
|
172
|
+
memAvailBytes: ForgeDeviceInsightsPlatform.availableMemoryBytes,
|
|
173
|
+
thermal: { ForgeThermalState.from(ProcessInfo.processInfo.thermalState) },
|
|
174
|
+
battery: ForgeDeviceInsightsPlatform.batteryReader,
|
|
175
|
+
batteryMonitoring: ForgeDeviceInsightsPlatform.batteryMonitoringControl
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/// Answers the kernel's device sample requests.
|
|
180
|
+
///
|
|
181
|
+
/// The kernel schedules; this collector only reads. It reads exactly the
|
|
182
|
+
/// families a request lists, on a utility queue, and forwards one
|
|
183
|
+
/// ``ForgeDeviceSample`` per request through `publish`. It never reads free
|
|
184
|
+
/// disk space (see ``ForgeDeviceSample/diskFreeBytes``), never reads battery
|
|
185
|
+
/// where the platform has no battery API, and arms `UIDevice` battery
|
|
186
|
+
/// monitoring only while the capture plan lists the `bat` family - restoring
|
|
187
|
+
/// the previous state afterwards, and never touching monitoring the host app
|
|
188
|
+
/// enabled itself.
|
|
189
|
+
///
|
|
190
|
+
/// Threading: every `UIDevice` touch happens on the sampling queue (which hops
|
|
191
|
+
/// to the main thread synchronously). Nothing ever waits on the sampling
|
|
192
|
+
/// queue - not `stop()`, not the runtime worker thread that delivers plans and
|
|
193
|
+
/// requests - so the main-thread hop cannot close a cycle with a facade
|
|
194
|
+
/// teardown or a kernel command in flight.
|
|
195
|
+
final class ForgeDeviceInsightsCollector {
|
|
196
|
+
private let readers: ForgeDeviceInsightsReaders
|
|
197
|
+
private let queue: DispatchQueue
|
|
198
|
+
private let publish: (ForgeDeviceSample) -> Void
|
|
199
|
+
private let lock = NSLock()
|
|
200
|
+
private var active = true
|
|
201
|
+
/// Whether the plan currently asks for battery monitoring to be armed.
|
|
202
|
+
private var batteryWanted = false
|
|
203
|
+
/// Whether Forge (not the host app) is the one who enabled monitoring.
|
|
204
|
+
private var armedByForge = false
|
|
205
|
+
|
|
206
|
+
init(
|
|
207
|
+
readers: ForgeDeviceInsightsReaders = .platform,
|
|
208
|
+
queue: DispatchQueue = DispatchQueue(label: "com.npaw.forge.device-insights", qos: .utility),
|
|
209
|
+
publish: @escaping (ForgeDeviceSample) -> Void
|
|
210
|
+
) {
|
|
211
|
+
self.readers = readers
|
|
212
|
+
self.queue = queue
|
|
213
|
+
self.publish = publish
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/// Pre-arms or disarms the cheap observers a family needs from the capture
|
|
217
|
+
/// plan: battery monitoring iff the plan carries the device-insights bit
|
|
218
|
+
/// AND lists `bat`. Nothing here reads a metric - the request does.
|
|
219
|
+
///
|
|
220
|
+
/// The plan arrives on the runtime's worker thread; the `UIDevice` work
|
|
221
|
+
/// hops to this collector's queue first, because that thread must never
|
|
222
|
+
/// wait on the main thread (a `destroy()` on main joins the worker).
|
|
223
|
+
func applyCapturePlan(_ plan: ForgeCapturePlan) {
|
|
224
|
+
let wanted = plan.isEnabled(ForgeCapturePlan.Bits.deviceInsights)
|
|
225
|
+
&& plan.deviceInsightFamilies.contains(ForgeDeviceMetricFamily.bat.rawValue)
|
|
226
|
+
lock.lock()
|
|
227
|
+
guard active else {
|
|
228
|
+
lock.unlock()
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
batteryWanted = wanted
|
|
232
|
+
lock.unlock()
|
|
233
|
+
queue.async { [self] in
|
|
234
|
+
if wanted {
|
|
235
|
+
armBatteryMonitoringIfNeeded()
|
|
236
|
+
} else {
|
|
237
|
+
disarmBatteryMonitoringIfArmed()
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/// Answers one kernel request. Unknown tokens are ignored (a newer Console
|
|
243
|
+
/// must never break sampling); `disk` is ignored on Apple by design.
|
|
244
|
+
func handleSampleRequest(_ families: [String]) {
|
|
245
|
+
let requested = Set(families.compactMap(ForgeDeviceMetricFamily.init(rawValue:)))
|
|
246
|
+
lock.lock()
|
|
247
|
+
let isActive = active
|
|
248
|
+
lock.unlock()
|
|
249
|
+
guard isActive, !requested.isEmpty else { return }
|
|
250
|
+
queue.async { [weak self] in
|
|
251
|
+
self?.sampleNow(requested)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/// Stops answering and returns battery monitoring to what it was. Safe to
|
|
256
|
+
/// call twice; never blocks on the sampling queue or the main thread, so it
|
|
257
|
+
/// can be called under a facade lock from any thread. The disarm runs on
|
|
258
|
+
/// the sampling queue and captures the control, not `self`, so it still
|
|
259
|
+
/// happens if the collector is released right after this call.
|
|
260
|
+
func stop() {
|
|
261
|
+
lock.lock()
|
|
262
|
+
let wasActive = active
|
|
263
|
+
active = false
|
|
264
|
+
batteryWanted = false
|
|
265
|
+
let disarm = armedByForge
|
|
266
|
+
armedByForge = false
|
|
267
|
+
lock.unlock()
|
|
268
|
+
guard wasActive, disarm, let control = readers.batteryMonitoring else { return }
|
|
269
|
+
queue.async {
|
|
270
|
+
control.setEnabled(false)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// MARK: - Sampling
|
|
275
|
+
|
|
276
|
+
private func sampleNow(_ requested: Set<ForgeDeviceMetricFamily>) {
|
|
277
|
+
var sample = ForgeDeviceSample(sampledAtMs: readers.nowMs())
|
|
278
|
+
if requested.contains(.cpu) {
|
|
279
|
+
// The two travel together or not at all: the kernel needs both to
|
|
280
|
+
// derive `dev_cpu`, and half a pair is a value nobody can use.
|
|
281
|
+
if let cpuTimeMs = readers.cpuTimeMs(), let cpuCores = readers.cpuCores() {
|
|
282
|
+
sample.cpuTimeMs = cpuTimeMs
|
|
283
|
+
sample.cpuCores = cpuCores
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if requested.contains(.mem) {
|
|
287
|
+
sample.memUsedBytes = readers.memUsedBytes()
|
|
288
|
+
sample.memAvailBytes = readers.memAvailBytes()
|
|
289
|
+
}
|
|
290
|
+
if requested.contains(.thm) {
|
|
291
|
+
sample.thermal = readers.thermal()
|
|
292
|
+
}
|
|
293
|
+
if requested.contains(.bat), let reading = readBattery() {
|
|
294
|
+
let fields = Self.batteryFields(from: reading)
|
|
295
|
+
sample.batteryLevelPct = fields.levelPct
|
|
296
|
+
sample.batteryCharging = fields.charging
|
|
297
|
+
}
|
|
298
|
+
// `disk` is deliberately never read: see ForgeDeviceSample.diskFreeBytes.
|
|
299
|
+
|
|
300
|
+
lock.lock()
|
|
301
|
+
let isActive = active
|
|
302
|
+
lock.unlock()
|
|
303
|
+
// A sample with nothing in it would only cost an FFI hop; the kernel
|
|
304
|
+
// keeps its previous sample and the diff sends nothing either way.
|
|
305
|
+
guard isActive, !sample.isEmpty else { return }
|
|
306
|
+
publish(sample)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/// Reads the battery where the platform has one. A request that lists
|
|
310
|
+
/// `bat` before the plan arrived (the kernel's first request fires at
|
|
311
|
+
/// delay 0) arms monitoring on the spot, so the first reading is real.
|
|
312
|
+
private func readBattery() -> ForgeBatteryReading? {
|
|
313
|
+
guard let read = readers.battery else { return nil }
|
|
314
|
+
armBatteryMonitoringIfNeeded()
|
|
315
|
+
return read()
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/// Shapes a raw reading into the wire fields, independently per field.
|
|
319
|
+
/// - level: `0...1` becomes `0...100` (rounded); anything else - UIKit
|
|
320
|
+
/// reports `-1` while the state is unknown - is absent, never clamped
|
|
321
|
+
/// into a fake reading.
|
|
322
|
+
/// - charging: `.charging`/`.full` → `true`, `.unplugged` → `false`,
|
|
323
|
+
/// `.unknown` → absent.
|
|
324
|
+
static func batteryFields(from reading: ForgeBatteryReading) -> (levelPct: UInt8?, charging: Bool?) {
|
|
325
|
+
var levelPct: UInt8?
|
|
326
|
+
if reading.level >= 0, reading.level <= 1 {
|
|
327
|
+
levelPct = UInt8(min(100, max(0, (reading.level * 100).rounded())))
|
|
328
|
+
}
|
|
329
|
+
let charging: Bool?
|
|
330
|
+
switch reading.state {
|
|
331
|
+
case .charging, .full: charging = true
|
|
332
|
+
case .unplugged: charging = false
|
|
333
|
+
case .unknown: charging = nil
|
|
334
|
+
}
|
|
335
|
+
return (levelPct, charging)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// MARK: - Battery monitoring
|
|
339
|
+
|
|
340
|
+
private func armBatteryMonitoringIfNeeded() {
|
|
341
|
+
guard let control = readers.batteryMonitoring else { return }
|
|
342
|
+
lock.lock()
|
|
343
|
+
let shouldArm = active && !armedByForge
|
|
344
|
+
lock.unlock()
|
|
345
|
+
guard shouldArm else { return }
|
|
346
|
+
// Monitoring the host app already enabled is left alone, and is never
|
|
347
|
+
// disarmed by Forge later: `armedByForge` stays false.
|
|
348
|
+
if control.isEnabled() { return }
|
|
349
|
+
control.setEnabled(true)
|
|
350
|
+
lock.lock()
|
|
351
|
+
// `stop()` may have raced this arm; if so, undo it right away.
|
|
352
|
+
let undo = !active
|
|
353
|
+
armedByForge = !undo
|
|
354
|
+
lock.unlock()
|
|
355
|
+
if undo {
|
|
356
|
+
control.setEnabled(false)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private func disarmBatteryMonitoringIfArmed() {
|
|
361
|
+
guard let control = readers.batteryMonitoring else { return }
|
|
362
|
+
lock.lock()
|
|
363
|
+
let shouldDisarm = armedByForge && !batteryWanted
|
|
364
|
+
if shouldDisarm {
|
|
365
|
+
armedByForge = false
|
|
366
|
+
}
|
|
367
|
+
lock.unlock()
|
|
368
|
+
guard shouldDisarm else { return }
|
|
369
|
+
control.setEnabled(false)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/// Test seam: runs `body` after everything already queued on the sampling
|
|
373
|
+
/// queue - a plan applied or a `stop()` issued just before is settled.
|
|
374
|
+
func afterPendingWork(_ body: @escaping () -> Void) {
|
|
375
|
+
queue.async(execute: body)
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/// The real Darwin/Foundation/UIKit reads behind ``ForgeDeviceInsightsReaders/platform``.
|
|
380
|
+
///
|
|
381
|
+
/// Verified against the Xcode 26.2 SDK headers (`mach/task_info.h`,
|
|
382
|
+
/// `os/proc.h`, `_time.h`, `NSProcessInfo.h`, `UIKit/UIDevice.h`) - see
|
|
383
|
+
/// docs/internal/device-insights.md for the review record.
|
|
384
|
+
enum ForgeDeviceInsightsPlatform {
|
|
385
|
+
/// Cumulative process CPU time, user + system, from
|
|
386
|
+
/// `clock_gettime(CLOCK_PROCESS_CPUTIME_ID)` (iOS 10 / tvOS 10 / macOS
|
|
387
|
+
/// 10.12+), which "tracks the amount of CPU (in user- or kernel-mode) used
|
|
388
|
+
/// by the calling process" across all its threads.
|
|
389
|
+
///
|
|
390
|
+
/// NOT `task_info(TASK_BASIC_INFO)`: its `user_time`/`system_time` count
|
|
391
|
+
/// terminated threads only (the header says so, and a measurement showed
|
|
392
|
+
/// it stuck at 0 while a live thread burned 260 ms), so an app whose
|
|
393
|
+
/// threads live for the whole session would report no CPU at all.
|
|
394
|
+
static func processCpuTimeMs() -> UInt64? {
|
|
395
|
+
var spec = timespec()
|
|
396
|
+
guard clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &spec) == 0,
|
|
397
|
+
spec.tv_sec >= 0, spec.tv_nsec >= 0 else {
|
|
398
|
+
return nil
|
|
399
|
+
}
|
|
400
|
+
return UInt64(spec.tv_sec) * 1000 + UInt64(spec.tv_nsec) / 1_000_000
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/// `ProcessInfo.activeProcessorCount` (iOS 2 / tvOS 9+): the logical
|
|
404
|
+
/// cores currently available to the process, which is what the kernel
|
|
405
|
+
/// divides the CPU delta by. `0` is not a core count and yields `nil`.
|
|
406
|
+
static func activeProcessorCount() -> UInt32? {
|
|
407
|
+
let count = ProcessInfo.processInfo.activeProcessorCount
|
|
408
|
+
return count >= 1 ? UInt32(clamping: count) : nil
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/// `task_vm_info.phys_footprint` via `task_info(TASK_VM_INFO)`: the
|
|
412
|
+
/// number Xcode's memory gauge and the jetsam limit are measured against.
|
|
413
|
+
static func physicalFootprintBytes() -> UInt64? {
|
|
414
|
+
var info = task_vm_info_data_t()
|
|
415
|
+
let stride = MemoryLayout<natural_t>.size
|
|
416
|
+
var count = mach_msg_type_number_t(MemoryLayout<task_vm_info_data_t>.size / stride)
|
|
417
|
+
let result = withUnsafeMutablePointer(to: &info) { pointer in
|
|
418
|
+
pointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
|
|
419
|
+
task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count)
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
guard result == KERN_SUCCESS else { return nil }
|
|
423
|
+
// The kernel reports how much of the struct it filled; `phys_footprint`
|
|
424
|
+
// is a rev1 field (present since iOS 9), but a shorter reply must not
|
|
425
|
+
// be read past its end.
|
|
426
|
+
guard let offset = MemoryLayout<task_vm_info_data_t>.offset(of: \task_vm_info_data_t.phys_footprint),
|
|
427
|
+
offset + MemoryLayout<mach_vm_size_t>.size <= Int(count) * stride else {
|
|
428
|
+
return nil
|
|
429
|
+
}
|
|
430
|
+
guard info.phys_footprint > 0 else { return nil }
|
|
431
|
+
return UInt64(info.phys_footprint)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/// `os_proc_available_memory()` (iOS 13 / tvOS 13+, unavailable on
|
|
435
|
+
/// macOS): "the number of bytes remaining, at the time of the call, before
|
|
436
|
+
/// the current process will hit its current dirty memory limit" - the
|
|
437
|
+
/// app's own headroom, not the device's free RAM. The header documents
|
|
438
|
+
/// `0` as "the calling process is not an app, or exceeds its memory
|
|
439
|
+
/// limit"; neither is a headroom worth reporting, so `0` is absent.
|
|
440
|
+
static func availableMemoryBytes() -> UInt64? {
|
|
441
|
+
#if os(iOS) || os(tvOS)
|
|
442
|
+
let bytes = os_proc_available_memory()
|
|
443
|
+
return bytes == 0 ? nil : UInt64(bytes)
|
|
444
|
+
#else
|
|
445
|
+
return nil
|
|
446
|
+
#endif
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
#if os(iOS)
|
|
450
|
+
/// `UIDevice.current.batteryLevel` / `batteryState`, read on the main
|
|
451
|
+
/// thread like every other `UIDevice` access. Both are
|
|
452
|
+
/// `API_UNAVAILABLE(tvos)`, hence the whole reader is iOS-only.
|
|
453
|
+
static let batteryReader: (() -> ForgeBatteryReading?)? = {
|
|
454
|
+
onMainThread {
|
|
455
|
+
let device = UIDevice.current
|
|
456
|
+
return ForgeBatteryReading(level: device.batteryLevel, state: batteryState(device.batteryState))
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
static let batteryMonitoringControl: ForgeBatteryMonitoringControl? = ForgeBatteryMonitoringControl(
|
|
461
|
+
isEnabled: { onMainThread { UIDevice.current.isBatteryMonitoringEnabled } },
|
|
462
|
+
setEnabled: { enabled in onMainThread { UIDevice.current.isBatteryMonitoringEnabled = enabled } }
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
private static func batteryState(_ state: UIDevice.BatteryState) -> ForgeBatteryReading.State {
|
|
466
|
+
switch state {
|
|
467
|
+
case .unknown: return .unknown
|
|
468
|
+
case .unplugged: return .unplugged
|
|
469
|
+
case .charging: return .charging
|
|
470
|
+
case .full: return .full
|
|
471
|
+
@unknown default: return .unknown
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/// `UIDevice` is a main-actor object; the collector samples on a utility
|
|
476
|
+
/// queue, so the few battery reads hop to the main thread synchronously.
|
|
477
|
+
/// The collector never holds its lock across this hop and `stop()` never
|
|
478
|
+
/// waits on the sampling queue, so a main thread tearing Forge down cannot
|
|
479
|
+
/// deadlock against a sample in flight.
|
|
480
|
+
private static func onMainThread<T>(_ body: () -> T) -> T {
|
|
481
|
+
if Thread.isMainThread {
|
|
482
|
+
return body()
|
|
483
|
+
}
|
|
484
|
+
return DispatchQueue.main.sync(execute: body)
|
|
485
|
+
}
|
|
486
|
+
#else
|
|
487
|
+
/// tvOS has no battery and no battery API (`UIDevice.batteryLevel`,
|
|
488
|
+
/// `batteryState` and `isBatteryMonitoringEnabled` are all
|
|
489
|
+
/// `API_UNAVAILABLE(tvos)`); the family is simply absent there, and on the
|
|
490
|
+
/// host Mac where the unit suite runs.
|
|
491
|
+
static let batteryReader: (() -> ForgeBatteryReading?)? = nil
|
|
492
|
+
static let batteryMonitoringControl: ForgeBatteryMonitoringControl? = nil
|
|
493
|
+
#endif
|
|
494
|
+
}
|
|
@@ -147,6 +147,11 @@ public final class ForgeApple {
|
|
|
147
147
|
private var capturePlanObservers: [UUID: (ForgeCapturePlan) -> Void] = [:]
|
|
148
148
|
private var latestCapturePlan: ForgeCapturePlan?
|
|
149
149
|
private var logObservers: [UUID: (ForgeRenderedLog) -> Void] = [:]
|
|
150
|
+
/// Guarded by its own lock, not `lock`: requests arrive on the runtime's
|
|
151
|
+
/// worker thread, and `destroy()` holds `lock` while the runtime joins
|
|
152
|
+
/// that worker - a request landing in that window must not need `lock`.
|
|
153
|
+
private let deviceSampleRequestLock = NSLock()
|
|
154
|
+
private var deviceSampleRequestObservers: [UUID: ([String]) -> Void] = [:]
|
|
150
155
|
|
|
151
156
|
public init(config: ForgeAppleConfig, services: ForgeAppleServices) {
|
|
152
157
|
let safeConfig = config.withPluginVersion(NPAWForge.defaultPluginVersion)
|
|
@@ -162,6 +167,11 @@ public final class ForgeApple {
|
|
|
162
167
|
_ = runtime.setLogListener { [weak self] log in
|
|
163
168
|
self?.dispatchRenderedLog(log)
|
|
164
169
|
}
|
|
170
|
+
// Also before start(): the kernel's first sample request fires at
|
|
171
|
+
// delay 0 once a carrier exists, and a request is never replayed.
|
|
172
|
+
_ = runtime.setDeviceSampleListener { [weak self] families in
|
|
173
|
+
self?.dispatchDeviceSampleRequest(families)
|
|
174
|
+
}
|
|
165
175
|
_ = runtime.start()
|
|
166
176
|
if safeConfig.startSessionOnInitialize {
|
|
167
177
|
_ = startSession()
|
|
@@ -398,6 +408,34 @@ public final class ForgeApple {
|
|
|
398
408
|
runtime.recordLocationFix(fix)
|
|
399
409
|
}
|
|
400
410
|
|
|
411
|
+
/// Forwards a device runtime health sample to the kernel. Called by the
|
|
412
|
+
/// automatic collector in answer to a kernel request; a host may also push
|
|
413
|
+
/// its own sample. Every field is optional and the kernel validates each
|
|
414
|
+
/// one; a sample sent while device insights are off remotely is refused.
|
|
415
|
+
@discardableResult
|
|
416
|
+
public func recordDeviceSample(_ sample: ForgeDeviceSample) -> ForgeOutcome {
|
|
417
|
+
lock.lock()
|
|
418
|
+
defer { lock.unlock() }
|
|
419
|
+
guard !destroyed else { return .ignoredInvalidState }
|
|
420
|
+
return runtime.recordDeviceSample(sample)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/// Observes the kernel's device sample requests: the family allowlist the
|
|
424
|
+
/// operator configured, once per sampling tick. Nothing is replayed to a
|
|
425
|
+
/// late observer. Internal - the facade installs the collector that
|
|
426
|
+
/// answers; hosts see only the sample it forwards.
|
|
427
|
+
func addDeviceSampleRequestListener(_ listener: @escaping ([String]) -> Void) -> ForgeEventSubscription {
|
|
428
|
+
let id = UUID()
|
|
429
|
+
deviceSampleRequestLock.lock()
|
|
430
|
+
deviceSampleRequestObservers[id] = listener
|
|
431
|
+
deviceSampleRequestLock.unlock()
|
|
432
|
+
return ForgeEventSubscription { [weak self] in
|
|
433
|
+
self?.deviceSampleRequestLock.lock()
|
|
434
|
+
self?.deviceSampleRequestObservers.removeValue(forKey: id)
|
|
435
|
+
self?.deviceSampleRequestLock.unlock()
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
401
439
|
public func effectiveLogLevel() -> ForgeLogLevel {
|
|
402
440
|
logger.effectiveLevel()
|
|
403
441
|
}
|
|
@@ -559,6 +597,15 @@ public final class ForgeApple {
|
|
|
559
597
|
observers.forEach { $0(plan) }
|
|
560
598
|
}
|
|
561
599
|
|
|
600
|
+
/// Runs on the runtime's worker thread; observers must not block it. After
|
|
601
|
+
/// `destroy()` the registry is empty, so a late request finds nobody.
|
|
602
|
+
private func dispatchDeviceSampleRequest(_ families: [String]) {
|
|
603
|
+
deviceSampleRequestLock.lock()
|
|
604
|
+
let observers = Array(deviceSampleRequestObservers.values)
|
|
605
|
+
deviceSampleRequestLock.unlock()
|
|
606
|
+
observers.forEach { $0(families) }
|
|
607
|
+
}
|
|
608
|
+
|
|
562
609
|
public func destroyVideoView(_ view: RuntimeVideoView) -> ForgeOutcome {
|
|
563
610
|
runtime.destroyVideoView(view)
|
|
564
611
|
}
|
|
@@ -580,10 +627,14 @@ public final class ForgeApple {
|
|
|
580
627
|
onSendRequestListeners.removeAll()
|
|
581
628
|
capturePlanObservers.removeAll()
|
|
582
629
|
logObservers.removeAll()
|
|
630
|
+
deviceSampleRequestLock.lock()
|
|
631
|
+
deviceSampleRequestObservers.removeAll()
|
|
632
|
+
deviceSampleRequestLock.unlock()
|
|
583
633
|
recordedEvents.removeAll()
|
|
584
634
|
_ = runtime.registerSessionPreSendHook(nil)
|
|
585
635
|
_ = runtime.setCapturePlanListener(nil)
|
|
586
636
|
_ = runtime.setLogListener(nil)
|
|
637
|
+
_ = runtime.setDeviceSampleListener(nil)
|
|
587
638
|
return runtime.destroy()
|
|
588
639
|
}
|
|
589
640
|
|
|
@@ -33,6 +33,12 @@ public struct ForgeCapturePlan: Equatable {
|
|
|
33
33
|
public let behavior: Behavior
|
|
34
34
|
public let sections: Sections
|
|
35
35
|
public let attribution: Attribution
|
|
36
|
+
/// Device insights metric families the operator allowed (`di.m`), as the
|
|
37
|
+
/// canonical tokens `cpu`, `mem`, `bat`, `thm`, `disk` in canonical order.
|
|
38
|
+
/// Empty whenever ``Bits/deviceInsights`` is clear. Its only purpose on
|
|
39
|
+
/// Apple is pre-arming the observer a family needs (battery monitoring
|
|
40
|
+
/// for `bat`); the kernel's sample request, not the plan, triggers a read.
|
|
41
|
+
public let deviceInsightFamilies: [String]
|
|
36
42
|
/// Canonical plan hash as a decimal string (full 64-bit value).
|
|
37
43
|
public let hash: String
|
|
38
44
|
|
|
@@ -45,6 +51,7 @@ public struct ForgeCapturePlan: Equatable {
|
|
|
45
51
|
behavior: Behavior = Behavior(),
|
|
46
52
|
sections: Sections = Sections(),
|
|
47
53
|
attribution: Attribution = Attribution(),
|
|
54
|
+
deviceInsightFamilies: [String] = [],
|
|
48
55
|
hash: String
|
|
49
56
|
) {
|
|
50
57
|
self.planSeq = planSeq
|
|
@@ -55,6 +62,7 @@ public struct ForgeCapturePlan: Equatable {
|
|
|
55
62
|
self.behavior = behavior
|
|
56
63
|
self.sections = sections
|
|
57
64
|
self.attribution = attribution
|
|
65
|
+
self.deviceInsightFamilies = deviceInsightFamilies
|
|
58
66
|
self.hash = hash
|
|
59
67
|
}
|
|
60
68
|
|
|
@@ -211,6 +219,10 @@ public struct ForgeCapturePlan: Equatable {
|
|
|
211
219
|
/// observe location. Without this bit Forge reads no location at all,
|
|
212
220
|
/// not even the authorization status.
|
|
213
221
|
public static let location: UInt32 = 1 << 15
|
|
222
|
+
/// The operator enabled device insights (`di.en` with a non-empty
|
|
223
|
+
/// family allowlist); ``ForgeCapturePlan/deviceInsightFamilies`` lists
|
|
224
|
+
/// the families. Set iff the kernel will be scheduling sample requests.
|
|
225
|
+
public static let deviceInsights: UInt32 = 1 << 16
|
|
214
226
|
}
|
|
215
227
|
}
|
|
216
228
|
|
|
@@ -252,6 +264,7 @@ extension ForgeCapturePlan {
|
|
|
252
264
|
attributionIncludeLandingUrl: Bool,
|
|
253
265
|
attributionDeriveFromDeepLinks: Bool,
|
|
254
266
|
attributionInstallReferrerEnabled: Bool,
|
|
267
|
+
deviceInsightFamilies: [String],
|
|
255
268
|
hash: String
|
|
256
269
|
) -> ForgeCapturePlan {
|
|
257
270
|
func i64(_ value: UInt64) -> Int64 {
|
|
@@ -295,6 +308,7 @@ extension ForgeCapturePlan {
|
|
|
295
308
|
includeLandingUrl: attributionIncludeLandingUrl,
|
|
296
309
|
deriveFromDeepLinks: attributionDeriveFromDeepLinks
|
|
297
310
|
),
|
|
311
|
+
deviceInsightFamilies: deviceInsightFamilies,
|
|
298
312
|
hash: hash
|
|
299
313
|
)
|
|
300
314
|
}
|