@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.
Files changed (20) hide show
  1. package/android/build.gradle +1 -1
  2. package/ios/ForgeApple/ForgeKernelFFI.xcframework/Info.plist +7 -7
  3. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/Headers/forge_kernelFFI.h +60 -0
  4. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/libForgeKernelFFI.a +0 -0
  5. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/Headers/forge_kernelFFI.h +60 -0
  6. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/libForgeKernelFFI.a +0 -0
  7. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/Headers/forge_kernelFFI.h +60 -0
  8. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/libForgeKernelFFI.a +0 -0
  9. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/Headers/forge_kernelFFI.h +60 -0
  10. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/libForgeKernelFFI.a +0 -0
  11. package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernel.swift +415 -1
  12. package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernelFFI.h +60 -0
  13. package/ios/ForgeApple/Sources/NPAWForgeAVPlayerAdapter/AVPlayerAdapter.swift +22 -1
  14. package/ios/ForgeApple/Sources/NPAWForgeCore/DeviceInsightsCollector.swift +494 -0
  15. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeApple.swift +51 -0
  16. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeCapturePlan.swift +14 -0
  17. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeRuntime.swift +48 -0
  18. package/ios/ForgeApple/Sources/NPAWForgeCore/NPAWForge.swift +49 -1
  19. package/ios/ForgeApple/Sources/NPAWForgeCore/NativeUniFFIRuntime.swift +77 -0
  20. 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
  }