@everframe/react-native 0.9.0

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 (38) hide show
  1. package/EverframeRN.podspec +88 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +8 -0
  4. package/README.md +505 -0
  5. package/android/build.gradle.kts +209 -0
  6. package/android/settings.gradle.kts +8 -0
  7. package/android/src/main/AndroidManifest.xml +9 -0
  8. package/android/src/main/java/dev/everframe/rn/EverframeModule.kt +1670 -0
  9. package/android/src/main/java/dev/everframe/rn/EverframePackage.kt +40 -0
  10. package/android/src/main/java/dev/everframe/rn/EverframeSensitiveViewManager.kt +20 -0
  11. package/android/src/main/java/dev/everframe/rn/RnVideoPrivacyAdapter.kt +65 -0
  12. package/dist/NativeEverframe.d.ts +308 -0
  13. package/dist/NativeEverframe.js +1 -0
  14. package/dist/browser-ZQAY6B4M.js +1 -0
  15. package/dist/chunk-LPIKPZNX.js +1 -0
  16. package/dist/chunk-QRPINMCR.js +1 -0
  17. package/dist/chunk-QZRTTQRS.js +1 -0
  18. package/dist/chunk-XFLFA52R.js +1 -0
  19. package/dist/chunk-XUSV3KOD.js +1 -0
  20. package/dist/index.d.ts +1006 -0
  21. package/dist/index.js +51 -0
  22. package/dist/integrations/console.d.ts +8 -0
  23. package/dist/integrations/console.js +1 -0
  24. package/dist/integrations/react-native-video.d.ts +44 -0
  25. package/dist/integrations/react-native-video.js +1 -0
  26. package/dist/integrations/react-navigation.d.ts +21 -0
  27. package/dist/integrations/react-navigation.js +1 -0
  28. package/dist/integrations/theoplayer.d.ts +37 -0
  29. package/dist/integrations/theoplayer.js +1 -0
  30. package/dist/types-D5e4kWaw.d.ts +12 -0
  31. package/ios/Sources/EverframeBridge.swift +1043 -0
  32. package/ios/Sources/EverframeEventEmitter.swift +204 -0
  33. package/ios/Sources/EverframeModule.h +36 -0
  34. package/ios/Sources/EverframeModule.mm +489 -0
  35. package/ios/Sources/RemoteVitalsBridge.swift +99 -0
  36. package/package.json +121 -0
  37. package/src/NativeEverframe.ts +439 -0
  38. package/src/codegen-types.ts +11 -0
@@ -0,0 +1,1043 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 ScriptX
3
+ //
4
+ // Swift glue layer for the React Native TurboModule bridge.
5
+ //
6
+ // CONTRACT (Plan 06-02 + D-05/D-07 flip 2026-05-11 — DO NOT widen without a
7
+ // new D-decision):
8
+ //
9
+ // The RN bridge surface is the Phase 06 5-method TurboModule (configure,
10
+ // openReporter, registerSensitiveRect, setExtra, attachReactTree). The native
11
+ // EFReporterPresenter owns the entire report UX (capture + annotate + redact +
12
+ // submit) on iOS/iPadOS. On tvOS the on-device modal reporter was removed —
13
+ // tvOS hosts call the companion start API and route reporting through the
14
+ // phone-companion (QR → phone browser SPA) flow instead, so openReporter()
15
+ // on tvOS resolves to .cancelled by way of the absent presenter installer.
16
+ //
17
+ // 1. configure(opts) → start Everframe.shared with EverframeConfig
18
+ // + install EFReporterPresenter resolver
19
+ // 2. openReporter() → await Everframe.shared.report.open()
20
+ // resolves with { status, reportId? }
21
+ // 3. registerSensitiveRect(tag) → SensitiveRectRegistry.mark(view)
22
+ //
23
+ // Strict invariants:
24
+ // • NO new public method is added to `Everframe.shared`.
25
+ // • Every method guards on `Everframe.shared.captureGate`.
26
+ // • The presenter resolver is installed exactly once per process (idempotent
27
+ // installer + a local flag for defense-in-depth).
28
+ import Foundation
29
+ import Combine
30
+ import os.log
31
+ import UIKit
32
+ import EverframeKit
33
+ import EverframeReporterUI // tvOS slice present per Package.swift:12 (TV-03 of Phase 06.1)
34
+
35
+ @objc public final class EverframeBridge: NSObject {
36
+
37
+ private static let log = OSLog(subsystem: "dev.everframe.rn", category: "bridge")
38
+
39
+ // One-shot resolver install flag. `EFReporterPresenter.installResolver()`
40
+ // itself is idempotent at the sdk-ios layer, but a local guard avoids the
41
+ // os_log spam and the redundant `report.isPresenting` rewire.
42
+ nonisolated(unsafe) private static var resolverInstalled = false
43
+ private static let resolverInstallLock = NSLock()
44
+
45
+ // MARK: - configure
46
+
47
+ @objc public static func configure(
48
+ appId: NSString,
49
+ endpoint: NSString,
50
+ networkBodiesDisabled: Bool,
51
+ installIdentifierDisabled: Bool,
52
+ attachPinUi: NSString?,
53
+ companionDeviceId: NSString?,
54
+ companionBadgeEnabled: Bool,
55
+ shakeToReportEnabled: Bool,
56
+ companionBadgePosition: NSString?,
57
+ theme: NSDictionary?,
58
+ vitalsEnabled: NSNumber?,
59
+ vitalsSampleRate: NSNumber?,
60
+ vitalsCaptureSourceQuery: NSNumber?
61
+ ) throws {
62
+ // `endpoint` arg is retained for ObjC ABI compatibility with the
63
+ // codegen .mm — value is ignored. The SDK bakes the ingest URL at
64
+ // compile time (sdk-ios IngestEndpoint).
65
+ _ = endpoint
66
+ os_log("[everframe] configure appId=%{public}@", log: log, type: .info, appId as String)
67
+ // Polarity flips exactly once, here: JS sends the veto
68
+ // (`networkBodiesDisabled`), native stores the permission.
69
+ var cfg = EverframeConfig(appId: appId as String)
70
+ cfg.capture.networkBodies = !networkBodiesDisabled
71
+ // Polarity flips exactly once, here: JS sends the veto
72
+ // (`installIdentifierDisabled`), native stores the permission —
73
+ // identical to `networkBodies` above.
74
+ cfg.installIdentifierEnabled = !installIdentifierDisabled
75
+ // Explicit companion device-identity override (naming spec
76
+ // 2026-08-24) — read straight off the flat ConfigOpts field, same
77
+ // shape as attachPinUi below. nil/absent falls through to the
78
+ // Keychain-then-stored-UUID chain inside CompanionDeviceId.resolve.
79
+ cfg.companionDeviceId = companionDeviceId as String?
80
+ // Companion on-screen name badge (naming spec 2026-08-24, controller
81
+ // ruling / Task 6b) — read straight off the flat ConfigOpts fields,
82
+ // same shape as companionDeviceId above. `companionBadgeEnabled` is
83
+ // already defaulted to `true` by the .mm's `.value_or(true)`;
84
+ // `companionBadgePosition` stays the raw string here — parsed into
85
+ // `CompanionBadgePosition` only at `startCompanion()`, since a
86
+ // position can't affect anything before a relay client (and its
87
+ // badge) exist, mirroring `companionAttachPinUi` below.
88
+ cfg.companionBadgeEnabled = companionBadgeEnabled
89
+ cfg.shakeToReportEnabled = shakeToReportEnabled
90
+ cfg.companionBadgePosition = companionBadgePosition as String?
91
+ // Inline reporter theme (reporter branding spec 2026-08-25, RN
92
+ // slice). The .mm collapses the 8 flat ConfigOpts `theme*` wire
93
+ // fields into one role-keyed dictionary (nil when the host set no
94
+ // theme at all), so this ObjC seam gains ONE parameter instead of
95
+ // eight. Passthrough only — ThemeResolver revalidates hex per-field
96
+ // at presentation time and the server watermark entitlement gates
97
+ // rendering; an invalid or unentitled theme changes nothing here.
98
+ cfg.theme = Self.parseTheme(theme)
99
+ // Session Vitals (spec 2026-09-06) — the three flat ConfigOpts fields
100
+ // the .mm reads off `opts`. PRESENT FIELDS ONLY: each assignment is
101
+ // guarded, so an absent field keeps `VitalsConfig`'s own default
102
+ // rather than being written with a coerced zero value. That is why
103
+ // they cross as `NSNumber?` and not `Bool`/`Double` — absent must stay
104
+ // distinguishable from `false`/`0` (nil `enabled` follows the
105
+ // dashboard toggle; nil `sampleRate` follows the server rate).
106
+ if let vitalsEnabled { cfg.vitals.enabled = vitalsEnabled.boolValue }
107
+ if let vitalsSampleRate { cfg.vitals.sampleRate = vitalsSampleRate.doubleValue }
108
+ if let vitalsCaptureSourceQuery { cfg.vitals.captureSourceQuery = vitalsCaptureSourceQuery.boolValue }
109
+ // The attach-PIN UI mode for startCompanion() to read (spec 2026-08-19). configure()
110
+ // precedes startCompanion() in every host's lifecycle, so this is the natural place to
111
+ // parse the flat ConfigOpts string into the native enum; startCompanion is where it is
112
+ // actually consumed (RelayWSClient's announce arg + the suppression seam), since a mode
113
+ // can't affect anything before a relay client exists. Parsed into a LOCAL: the gate
114
+ // below compares it against the stashed value, and only the start path adopts it.
115
+ let newAttachPinUi = Self.parseAttachPinUi(attachPinUi as String?)
116
+ // Codex round-6, H1 — IDEMPOTENT configure, twin of `EverframeModule.configure`'s gate on
117
+ // Android, and decided against the INSTALLED config rather than a cache of the last
118
+ // options this bridge started with.
119
+ //
120
+ // `Everframe.shared.start(...)` SUPERSEDES the running SDK: the outgoing controller's
121
+ // shutdown detaches every integration it had announced, so a Provider that merely
122
+ // REMOUNTS with the same config used to kill every live player registration for
123
+ // nothing. An identical configuration installs identical state, so the cheapest correct
124
+ // answer is not to start at all.
125
+ //
126
+ // Round-5 kept a cached snapshot of the options it last started with, and a cache can
127
+ // disagree with the singleton it claims to describe: two overlapping configures (A then
128
+ // B) can interleave so the cache says A while the SDK runs B, and a native host calling
129
+ // `Everframe.shared.start(configB)` between two bridge configures of A reproduces it with
130
+ // no concurrency at all — the second A matches the stale cached A, skips, and leaves B
131
+ // installed under a JS host that believes it configured A. `currentConfig` cannot drift:
132
+ // it IS the installed state.
133
+ //
134
+ // `captureGate` is the second term: a killed SDK must be restarted even by an identical
135
+ // config, or the host is left with a dead SDK and no way to revive it.
136
+ //
137
+ // Codex round-7, I3 — the comparison is the WHOLE config (`EverframeConfig` is now
138
+ // `Equatable`, matching Android's `data class`), not a rendering of the fields this
139
+ // bridge happens to set. Round-6 hand-built a string snapshot here, which meant any
140
+ // config field the snapshot's author had not listed — a new one, or one a native host
141
+ // set directly — read as "unchanged" and silently suppressed a start that was needed.
142
+ // `attachPinUi` is the one term that is NOT part of the config (it lands in module
143
+ // state), so it is compared separately.
144
+ //
145
+ // Everything else this method does still runs on the skip path — the presenter-resolver
146
+ // install and the crash-metadata prime — because both are idempotent and neither
147
+ // depends on a fresh start. The attach-PIN stash is the exception, and only nominally:
148
+ // on the skip path it would write the value it already holds.
149
+ let unchanged = Everframe.shared.captureGate
150
+ && Everframe.shared.currentConfig == cfg
151
+ && companionAttachPinUi == newAttachPinUi
152
+ if unchanged {
153
+ os_log("[everframe] configure: SDK already running this exact config — skipping start()",
154
+ log: log, type: .debug)
155
+ } else {
156
+ try Everframe.shared.start(config: cfg)
157
+ // Adopted only on the start path — the skip path has already established that the
158
+ // stashed value equals this one.
159
+ companionAttachPinUi = newAttachPinUi
160
+ }
161
+ installPresenterResolverIfNeeded()
162
+ // Spec 2026-09-17 setExtra-resolver — installed unconditionally,
163
+ // every configure(), NOT gated behind startCompanion() the way the
164
+ // companion Combine subscriptions are: this must also cover the
165
+ // plain in-app openReporter() path and native shake, both of which
166
+ // funnel through Everframe.shared.report.open() →
167
+ // __consumePendingAttachments() with no companion involvement at
168
+ // all. See `installExtraResolveHook()`'s own doc comment.
169
+ installExtraResolveHook()
170
+ // Task 13: prime the device-metadata cache off the main queue so a
171
+ // crash arriving shortly after configure() still has device context.
172
+ CrashReporter.prime()
173
+ }
174
+
175
+ /// Build `ReporterThemeOptions` from the .mm's role-keyed dictionary
176
+ /// (reporter branding spec 2026-08-25, RN slice). `nil` dictionary — the
177
+ /// host set no theme — maps to `nil` options so `EverframeConfig.theme`
178
+ /// stays "host did nothing"; per-role absence is preserved the same way.
179
+ /// Values are NOT validated here: `ThemeResolver` owns hex validation
180
+ /// per-field at presentation time.
181
+ private static func parseTheme(_ raw: NSDictionary?) -> ReporterThemeOptions? {
182
+ guard let raw else { return nil }
183
+ func role(_ key: String) -> String? { raw[key] as? String }
184
+ return ReporterThemeOptions(
185
+ background: role("background"),
186
+ surface: role("surface"),
187
+ border: role("border"),
188
+ text: role("text"),
189
+ textMuted: role("textMuted"),
190
+ accent: role("accent"),
191
+ accentForeground: role("accentForeground"),
192
+ destructive: role("destructive")
193
+ )
194
+ }
195
+
196
+ /// Coerce the flat `ConfigOpts.attachPinUi` string into `AttachPinUi`.
197
+ /// `nil` (field absent) and any unrecognised value both degrade to
198
+ /// `.builtin` — the safe default matching "host did nothing special".
199
+ private static func parseAttachPinUi(_ raw: String?) -> AttachPinUi {
200
+ guard let raw, let mode = AttachPinUi(rawValue: raw) else { return .builtin }
201
+ return mode
202
+ }
203
+
204
+ /// Coerce the flat `ConfigOpts.companionBadgePosition` string into
205
+ /// `CompanionBadgePosition` (naming spec 2026-08-24, controller ruling /
206
+ /// Task 6b). `nil` (field absent) and any unrecognised value both
207
+ /// degrade to `.bottomRight` — matches `CompanionBadgeOptions.position`'s
208
+ /// own default, so an absent field behaves exactly like never touching
209
+ /// the option at all.
210
+ private static func parseCompanionBadgePosition(_ raw: String?) -> CompanionBadgePosition {
211
+ switch raw {
212
+ case "bottom-left": return .bottomLeft
213
+ case "top-right": return .topRight
214
+ case "top-left": return .topLeft
215
+ default: return .bottomRight
216
+ }
217
+ }
218
+
219
+ /// Wires `EFReporterPresenter` as the resolver for `Everframe.shared.report
220
+ /// .open()`. Called from configure(); the first call installs, subsequent
221
+ /// calls no-op. tvOS has no on-device modal — reporting goes through the
222
+ /// phone-companion flow (QR → phone browser) — so the presenter type
223
+ /// itself is `#if !os(tvOS)`-gated in EverframeReporterUI. Mirror that
224
+ /// gate here so the bridge compiles for the tvOS slice.
225
+ private static func installPresenterResolverIfNeeded() {
226
+ #if !os(tvOS)
227
+ resolverInstallLock.lock()
228
+ let alreadyInstalled = resolverInstalled
229
+ resolverInstalled = true
230
+ resolverInstallLock.unlock()
231
+ guard !alreadyInstalled else { return }
232
+ DispatchQueue.main.async {
233
+ MainActor.assumeIsolated {
234
+ EFReporterPresenter.installResolver()
235
+ }
236
+ os_log("[everframe] installed EFReporterPresenter resolver", log: log, type: .info)
237
+ }
238
+ #else
239
+ os_log("[everframe] tvOS — EFReporterPresenter not installed; reporting routes through phone companion", log: log, type: .info)
240
+ #endif
241
+ }
242
+
243
+ // MARK: - Extra-resolver ask-and-wait (spec 2026-09-17 setExtra-resolver)
244
+
245
+ /// Identity token for the CURRENTLY installed
246
+ /// `Everframe.__pendingExtraResolveHook` closure (D1 fix — stale-teardown
247
+ /// race). Bumped every time `installExtraResolveHook()` (re)installs the
248
+ /// hook; `resetExtraResolverForTeardown(_:)` below compares a caller's
249
+ /// captured value against this to decide whether ITS installation is
250
+ /// still the live one. See that method's doc comment for the race this
251
+ /// closes and why a plain counter — not a `Bool` — is what makes the
252
+ /// comparison possible at all.
253
+ nonisolated(unsafe) private static var extraResolveHookGeneration: Int = 0
254
+ private static let extraResolveHookInstallLock = NSLock()
255
+
256
+ /// Installs `awaitJsExtraResolve` onto `Everframe.__pendingExtraResolveHook`
257
+ /// (see that property's doc comment on Everframe.swift). Called from
258
+ /// EVERY `configure()`, UNCONDITIONALLY — mirrors Android's
259
+ /// `EverframeModule.init` block installing its own `extraResolveHook`
260
+ /// field unconditionally at every module construction ("the newest
261
+ /// instance's closure always wins", per that field's doc comment),
262
+ /// applied here at iOS's per-`configure()`-call granularity since
263
+ /// `EverframeBridge` has no per-instance construction point of its own —
264
+ /// every method on it is `static`.
265
+ ///
266
+ /// D1 fix: this used to be `installExtraResolveHookIfNeeded()`, gated by
267
+ /// a once-per-process `Bool` flag so a second call was a no-op. That
268
+ /// flag is exactly what let a STALE module instance's delayed
269
+ /// `-invalidate` clobber a FRESH instance's install: once the stale
270
+ /// teardown reset the flag to false and nil'd the hook, nothing was left
271
+ /// to set it again — `configure()` had already run for the current
272
+ /// bundle and nothing calls it a second time. See
273
+ /// `resetExtraResolverForTeardown(_:)`'s doc comment for the full race.
274
+ ///
275
+ /// Reinstalling has no observable side effect beyond the generation
276
+ /// bump: `awaitJsExtraResolve` captures no per-call state — it reads
277
+ /// only process-static vars at invocation time — so calling this on
278
+ /// every `configure()` is cheap, harmless churn, same as Android already
279
+ /// accepts on every module construction.
280
+ ///
281
+ /// Returns the fresh generation so `EverframeModule.mm`'s `-configure:`
282
+ /// can capture it (via `currentExtraResolveHookGeneration()` below) and
283
+ /// hand it back to `resetExtraResolverForTeardown(_:)` at `-invalidate`
284
+ /// time. `@discardableResult` because `configure()` itself has no use
285
+ /// for the value — only the ObjC caller does, via the separate accessor.
286
+ @discardableResult
287
+ private static func installExtraResolveHook() -> Int {
288
+ extraResolveHookInstallLock.lock()
289
+ extraResolveHookGeneration += 1
290
+ let generation = extraResolveHookGeneration
291
+ extraResolveHookInstallLock.unlock()
292
+ Everframe.__pendingExtraResolveHook = { await awaitJsExtraResolve() }
293
+ return generation
294
+ }
295
+
296
+ /// ObjC-visible read of whichever generation is live RIGHT NOW — i.e.
297
+ /// whatever the most recent `installExtraResolveHook()` call minted.
298
+ /// `EverframeModule.mm`'s `-configure:` calls this immediately after
299
+ /// `configureWithAppId:...:error:` (which, on success or on the
300
+ /// already-running-unchanged skip path, calls `installExtraResolveHook()`
301
+ /// internally) and stashes the result on its own instance ivar for
302
+ /// `-invalidate` to hand back later. 0 means no `configure()` call has
303
+ /// ever successfully reached the install site.
304
+ @objc public static func currentExtraResolveHookGeneration() -> Int {
305
+ extraResolveHookInstallLock.lock()
306
+ defer { extraResolveHookInstallLock.unlock() }
307
+ return extraResolveHookGeneration
308
+ }
309
+
310
+ /// JS presence flag — see `setExtraResolverActive`'s own doc comment on
311
+ /// `NativeEverframe.ts`'s `Spec.setExtraResolverActive`. `nonisolated
312
+ /// (unsafe)` + a lock (not `@Volatile` — Swift has no such attribute):
313
+ /// written from the JS thread (`setExtraResolverActive` below) and read
314
+ /// from `awaitJsExtraResolve`, which runs wherever
315
+ /// `__consumePendingAttachments()`'s caller happens to run.
316
+ nonisolated(unsafe) private static var extraResolverActive = false
317
+ private static let extraResolverActiveLock = NSLock()
318
+
319
+ /// JS calls this from EVERY `setExtra` call — see the full contract on
320
+ /// `NativeEverframe.ts`'s `Spec.setExtraResolverActive`.
321
+ @objc public static func setExtraResolverActive(_ active: Bool) {
322
+ extraResolverActiveLock.lock()
323
+ extraResolverActive = active
324
+ extraResolverActiveLock.unlock()
325
+ }
326
+
327
+ /// Per-correlation_id continuation map, released by
328
+ /// `signalExtraResolverReady`. Mirrors `CompanionCaptureBridge`'s
329
+ /// `pendingAttachContinuations` exactly (same lock-guarded dictionary,
330
+ /// same "the timeout resumes it if JS never calls back" shape).
331
+ nonisolated(unsafe) private static var pendingExtraResolveContinuations:
332
+ [String: CheckedContinuation<Void, Never>] = [:]
333
+ private static let pendingExtraResolveLock = NSLock()
334
+
335
+ /// Installed onto `Everframe.__pendingExtraResolveHook`. No-ops
336
+ /// IMMEDIATELY — no event, no wait — unless JS has a resolver CURRENTLY
337
+ /// registered (`extraResolverActive`), which is what keeps a
338
+ /// string/object-only host's report path exactly as fast as before this
339
+ /// feature landed (requirement: "no behaviour change whatsoever").
340
+ ///
341
+ /// Otherwise mirrors `CompanionCaptureBridge.awaitJsReactTreeAttach`
342
+ /// exactly: send `EverframeEventEmitter.sendExtraResolveRequested`, then
343
+ /// suspend up to 250ms for JS's `signalExtraResolverReady` ack.
344
+ private static func awaitJsExtraResolve() async {
345
+ extraResolverActiveLock.lock()
346
+ let active = extraResolverActive
347
+ extraResolverActiveLock.unlock()
348
+ guard active else { return }
349
+
350
+ let correlationId = UUID().uuidString
351
+ await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
352
+ pendingExtraResolveLock.lock()
353
+ pendingExtraResolveContinuations[correlationId] = cont
354
+ pendingExtraResolveLock.unlock()
355
+ // Send AFTER registering the continuation so a fast JS callback
356
+ // can't lose the race — same ordering `awaitJsReactTreeAttach`
357
+ // uses.
358
+ EverframeEventEmitter.sendExtraResolveRequested(correlationId)
359
+ // 250ms bounded fallback — resume if JS hasn't called back by then.
360
+ DispatchQueue.global().asyncAfter(deadline: .now() + 0.25) {
361
+ resumeAndDropPendingExtraResolve(correlationId: correlationId)
362
+ }
363
+ }
364
+ }
365
+
366
+ /// JS handshake ack: called once the `everframe.extra.resolveRequested`
367
+ /// listener has — if a resolver is registered — pushed a freshly
368
+ /// resolved value through `setExtra` above. Idempotent — a late signal
369
+ /// after the 250ms fallback already fired is a harmless no-op, exactly
370
+ /// like `signalCompanionReportRequestReady` below.
371
+ @objc public static func signalExtraResolverReady(_ correlationId: NSString) {
372
+ resumeAndDropPendingExtraResolve(correlationId: correlationId as String)
373
+ }
374
+
375
+ private static func resumeAndDropPendingExtraResolve(correlationId: String) {
376
+ pendingExtraResolveLock.lock()
377
+ let cont = pendingExtraResolveContinuations.removeValue(forKey: correlationId)
378
+ pendingExtraResolveLock.unlock()
379
+ cont?.resume()
380
+ }
381
+
382
+ /// Module-instance teardown fix (review finding F4, spec 2026-09-17
383
+ /// setExtra-resolver). `extraResolverActive` is a process-static
384
+ /// (above), so nothing was ever clearing it on its own. Scenario this
385
+ /// fixes: an RN surface embedded in a native host, whose JS context is
386
+ /// torn down WITHOUT `runtime.unmount()` running (a hard reload, or the
387
+ /// host tearing down its `RCTBridge`/turbo module manager directly) — RN
388
+ /// still calls `-invalidate` on every module that responds to it (see
389
+ /// `EverframeModule.mm`'s own header comment), but `runtime.ts`'s
390
+ /// `unmount()` (the thing that would otherwise flip
391
+ /// `setExtraResolverActive(false)`) never runs. Left uncleared,
392
+ /// `extraResolverActive` stays `true` forever: EVERY later report —
393
+ /// even a pure-native one, with no live RN bridge at all — pays the
394
+ /// full 250ms bounded wait for a `signalExtraResolverReady` ack that
395
+ /// will never arrive. Fail-open still holds (the wait still resolves
396
+ /// the report), but the "zero added latency for a non-resolver host"
397
+ /// contract this feature promised breaks silently.
398
+ ///
399
+ /// Called from `-[EverframeModule invalidate]`, mirroring
400
+ /// `RemoteVitalsBridge.reset()`'s own lifetime doc comment.
401
+ ///
402
+ /// IDENTITY GUARD (D1 fix — stale-teardown race). `generation` is the
403
+ /// value `EverframeModule.mm`'s `-configure:` captured via
404
+ /// `currentExtraResolveHookGeneration()` right after ITS OWN
405
+ /// `configure()` call, stashed on that ObjC instance and handed back
406
+ /// here at `-invalidate` time. RN dispatches `-invalidate` on a
407
+ /// module's method queue, so a STALE module instance's `-invalidate`
408
+ /// can be DELIVERED AFTER a reloaded bundle's fresh module instance has
409
+ /// already run `configure()` and installed a new hook (bumping
410
+ /// `extraResolveHookGeneration`). Before this guard, the stale
411
+ /// instance's teardown unconditionally nil'd `Everframe
412
+ /// .__pendingExtraResolveHook` and cleared `extraResolverActive` — the
413
+ /// FRESH hook, out from under the new bundle. Because that fresh
414
+ /// `configure()` already ran and nothing calls it a second time,
415
+ /// nothing was left to reinstall: JS then calls `setExtra(resolver)`,
416
+ /// sets `extraResolverActive = true`, and native never asks again —
417
+ /// `extra` silently vanishes from every report for the rest of the
418
+ /// process, with no error anywhere.
419
+ ///
420
+ /// `generation == extraResolveHookGeneration` is true only when THIS
421
+ /// caller's installation is still the live one — exactly mirroring
422
+ /// Android's `Everframe.__pendingExtraResolveHook === extraResolveHook`
423
+ /// reference check (`EverframeModule.kt:570-574`). Android gets that
424
+ /// identity for free because each module INSTANCE owns a distinct
425
+ /// closure object it can compare by reference; `EverframeBridge` is
426
+ /// entirely `static` and has no such object, so a monotonically
427
+ /// incrementing generation counter plays the same role. A `generation`
428
+ /// of 0 (this caller's own `configure()` never successfully reached the
429
+ /// install site) also no-ops, same as a stale one.
430
+ ///
431
+ /// When the guard passes: any continuations still pending (a report
432
+ /// mid-ask-and-wait at teardown time) are resumed immediately instead of
433
+ /// idling out their own 250ms fallback, since nothing will ever answer
434
+ /// them now. When it fails, pending continuations are left alone too —
435
+ /// they may belong to the FRESH instance's own in-flight ask, and its
436
+ /// own 250ms fallback (already scheduled) is what will resolve them if
437
+ /// JS doesn't.
438
+ @objc public static func resetExtraResolverForTeardown(_ generation: Int) {
439
+ extraResolveHookInstallLock.lock()
440
+ let isCurrentInstallation = generation != 0 && generation == extraResolveHookGeneration
441
+ extraResolveHookInstallLock.unlock()
442
+ guard isCurrentInstallation else {
443
+ os_log("[everframe] resetExtraResolverForTeardown: stale instance (generation %ld) — a newer configure() is already live; leaving it untouched",
444
+ log: log, type: .info, generation)
445
+ return
446
+ }
447
+
448
+ extraResolverActiveLock.lock()
449
+ extraResolverActive = false
450
+ extraResolverActiveLock.unlock()
451
+
452
+ Everframe.__pendingExtraResolveHook = nil
453
+
454
+ pendingExtraResolveLock.lock()
455
+ let stillPending = pendingExtraResolveContinuations
456
+ pendingExtraResolveContinuations.removeAll()
457
+ pendingExtraResolveLock.unlock()
458
+ for (_, cont) in stillPending {
459
+ cont.resume()
460
+ }
461
+ }
462
+
463
+ /// Install-race fix (spec 2026-08-19 review finding 3). `configure()`
464
+ /// (above) queues the PIN presenter install via
465
+ /// `installPresenterResolverIfNeeded()`'s `DispatchQueue.main.async` —
466
+ /// note that call marks `resolverInstalled = true` BEFORE the async block
467
+ /// actually runs, so that flag alone cannot tell a caller here whether
468
+ /// `CompanionPinPresenter.install()` (which sets
469
+ /// `CompanionAPI.__builtinPinUiInstalled`) has genuinely finished.
470
+ ///
471
+ /// `startCompanion()` builds `RelayWSClient` and calls `connect()`
472
+ /// synchronously right after this; `connect()`'s announce path reads
473
+ /// `__builtinPinUiInstalled` from a freshly-spawned `Task` that can run
474
+ /// before the main-queue-dispatched install block does, especially early
475
+ /// in a host's launch sequence when the main queue is already busy. A
476
+ /// host that calls `startCompanion()` immediately after `configure()`
477
+ /// resolves — the ordinary RN lifecycle — would then read `false` for
478
+ /// the capability check for the ENTIRE session, so `.builtin` mode would
479
+ /// never advertise `supportsAttachPin` even though the presenter finishes
480
+ /// installing moments later.
481
+ ///
482
+ /// Fix: synchronously hop to MainActor and call
483
+ /// `CompanionPinPresenter.install()` directly — the same call
484
+ /// `EFReporterPresenter.installResolver()` itself makes — and WAIT for
485
+ /// it, before `startCompanion()` constructs the client. `install()` is
486
+ /// `@MainActor` and idempotent (its own internal `installed` flag), so
487
+ /// this is cheap on every call after the first: the common case (install
488
+ /// already genuinely finished by the time `startCompanion()` runs) pays
489
+ /// only a MainActor hop, not real work. Scoped to the smallest change
490
+ /// that closes the race — `installPresenterResolverIfNeeded()`'s async
491
+ /// dispatch and its own `resolverInstalled` flag are untouched, since
492
+ /// `EFReporterPresenter.installResolver()` also wires the full modal
493
+ /// reporter (openReporter's resolver), which this call does not need to
494
+ /// duplicate or wait for.
495
+ ///
496
+ /// Gate: `#if canImport(UIKit)`, matching `CompanionPinPresenter.swift`'s
497
+ /// own gate — NOT `#if !os(tvOS)`. Round-2 review finding 2: tvOS hosts
498
+ /// are a PRIMARY consumer of companion (see `CompanionPinPresenter.swift`
499
+ /// header + `RelayWSClient`'s), and only `EFReporterPresenter` (the full
500
+ /// modal reporter, iOS-only — tvOS has no on-device modal) needs the
501
+ /// `!os(tvOS)` exclusion `installPresenterResolverIfNeeded()` above
502
+ /// correctly keeps. Gating THIS call the same way silently left RN-tvOS
503
+ /// builtin hosts with `CompanionAPI.__builtinPinUiInstalled` permanently
504
+ /// false, so `startCompanion()`'s announce always reported
505
+ /// `supportsAttachPin: false` and every tvOS host fell back to legacy
506
+ /// one-click attach even though `CompanionPinPresenter` builds and runs
507
+ /// fine there.
508
+ private static func ensurePinPresenterInstalledSync() {
509
+ #if canImport(UIKit)
510
+ if Thread.isMainThread {
511
+ MainActor.assumeIsolated {
512
+ CompanionPinPresenter.install()
513
+ }
514
+ } else {
515
+ DispatchQueue.main.sync {
516
+ MainActor.assumeIsolated {
517
+ CompanionPinPresenter.install()
518
+ }
519
+ }
520
+ }
521
+ #endif
522
+ }
523
+
524
+ // MARK: - openReporter
525
+
526
+ /// Opens the native reporter modal and resolves with the user's outcome.
527
+ /// Routes through `Everframe.shared.report.open()` so any host-installed
528
+ /// observers (`report.isPresenting`) fire on the same code path used by
529
+ /// non-RN consumers.
530
+ ///
531
+ /// Result NSDictionary shape (matches JS facade ReporterResult):
532
+ /// - { status: "submitted", reportId: "<uuid>" }
533
+ /// - { status: "queued", reportId: "<uuid>" }
534
+ /// - { status: "cancelled" }
535
+ @objc public static func openReporter(
536
+ completion: @escaping (NSDictionary?, NSError?) -> Void
537
+ ) {
538
+ os_log("[everframe] openReporter.enter", log: log, type: .info)
539
+ guard Everframe.shared.captureGate else {
540
+ completion(nil, NSError(
541
+ domain: "EverframeBridge", code: 1100,
542
+ userInfo: [NSLocalizedDescriptionKey: "Everframe not started (captureGate=false). Call configure() first."]
543
+ ))
544
+ return
545
+ }
546
+
547
+ // Defense-in-depth: configure should have installed already, but a
548
+ // stray openReporter before configure (race in host useEffect order)
549
+ // shouldn't deadlock on the un-installed resolver.
550
+ installPresenterResolverIfNeeded()
551
+
552
+ Task {
553
+ do {
554
+ let result = try await Everframe.shared.report.open()
555
+ let dict: NSDictionary
556
+ switch result {
557
+ case .submitted(let id):
558
+ dict = ["status": "submitted", "reportId": id.uuidString]
559
+ case .queued(let id):
560
+ dict = ["status": "queued", "reportId": id.uuidString]
561
+ case .cancelled:
562
+ dict = ["status": "cancelled"]
563
+ }
564
+ completion(dict, nil)
565
+ } catch {
566
+ completion(nil, error as NSError)
567
+ }
568
+ }
569
+ }
570
+
571
+ // MARK: - registerSensitiveRect
572
+
573
+ /// Mark a view as sensitive. Routed through MainActor.assumeIsolated so the
574
+ /// outer @objc static stays non-MainActor (MainActor-isolated @objc statics
575
+ /// silently drop from the generated bridge header on Xcode 26.4 + Swift 5.10
576
+ /// framework targets). Parameter is NSObject? rather than UIView? to also
577
+ /// dodge the parallel UIView-typed-@objc-static skip path. The .mm callers
578
+ /// always pre-dispatch to main and pass a real UIView; the inner cast is
579
+ /// belt-and-suspenders.
580
+ @objc(markSensitive:)
581
+ public static func markSensitive(_ view: NSObject) {
582
+ dispatchPrecondition(condition: .onQueue(.main))
583
+ guard let uiView = view as? UIView else {
584
+ os_log("[everframe] markSensitive: expected UIView, got %{public}@; ignoring",
585
+ log: log, type: .error, String(describing: type(of: view)))
586
+ return
587
+ }
588
+ MainActor.assumeIsolated {
589
+ guard Everframe.shared.captureGate else { return }
590
+ os_log("[everframe] markSensitive view=%{public}@", log: log, type: .info, uiView)
591
+ SensitiveRectRegistry.mark(uiView)
592
+ }
593
+ }
594
+
595
+ // MARK: - reportCrash (Task 13 — crash/error reporting spec 2026-07-18)
596
+
597
+ /// Crash/error reporting. Synchronous: the RN fatal path must finish
598
+ /// persisting before RN's default handler aborts. Identifies itself as
599
+ /// `everframe-react-native` (distinct from `CrashReporter.captureFacts`'s
600
+ /// `"everframe-ios"` default) so envelopes are attributable to the RN SDK.
601
+ @objc public static func reportCrash(_ crashJson: NSString) -> Bool {
602
+ CrashReporter.captureFacts(json: crashJson as String, sdkName: "everframe-react-native")
603
+ }
604
+
605
+ /// Explicit handled capture acknowledges the core's completed encrypted enqueue.
606
+ @objc public static func captureHandledException(_ crashJson: NSString) -> Bool {
607
+ CrashReporter.captureHandledFacts(json: crashJson as String, sdkName: "everframe-react-native")
608
+ }
609
+
610
+ // MARK: - setExtra
611
+
612
+ /// Forward an opaque string from JS into Everframe.shared.setExtra.
613
+ /// Empty string clears the pending extra.
614
+ @objc public static func setExtra(_ value: NSString) {
615
+ let s = value as String
616
+ if s.isEmpty {
617
+ Everframe.shared.clearExtra()
618
+ } else {
619
+ Everframe.shared.setExtra(s)
620
+ }
621
+ }
622
+
623
+ // MARK: - addBreadcrumb (Plan 4 / Task 14)
624
+
625
+ /// Manual breadcrumb escape hatch. Forwards straight to
626
+ /// `Everframe.shared.addBreadcrumb(message:kind:level:data:)` (Task 5) —
627
+ /// NO validation/coercion happens here. That singleton already owns
628
+ /// kind/level/data coercion AND gating (no-op pre-start / while killed),
629
+ /// exactly like `setExtra`/`attachReactTree` above never re-check
630
+ /// `captureGate` themselves. `data` crosses as an `NSDictionary?` — the
631
+ /// codegen-typed `UnsafeObject` boundary for freeform objects; a
632
+ /// non-dictionary payload (shouldn't happen via codegen, but fail-soft
633
+ /// regardless) is treated as absent rather than crashing the bridge.
634
+ @objc public static func addBreadcrumb(
635
+ _ message: NSString,
636
+ kind: NSString?,
637
+ level: NSString?,
638
+ data: NSDictionary?
639
+ ) {
640
+ let resolvedData = data as? [String: Any]
641
+ Everframe.shared.addBreadcrumb(
642
+ message: message as String,
643
+ kind: kind as String?,
644
+ level: level as String?,
645
+ data: resolvedData
646
+ )
647
+ }
648
+
649
+ // MARK: - setUser (self-declared identity — spec 2026-08-12)
650
+
651
+ /// Forward a host-declared user from JS into `Everframe.shared.setUser`.
652
+ /// Pure pass-through: the native singleton owns storage — NO
653
+ /// validation/coercion happens here, same contract as `setExtra`/
654
+ /// `attachReactTree` above. `nil` clears the active user (the bridge
655
+ /// forbids `T | null`, so JS expresses "clear" by calling with no
656
+ /// argument, which crosses as `nil` here). A missing key on the incoming
657
+ /// dictionary must become `nil`, never an empty string — that's what
658
+ /// keeps the Task 9 email-only `EFUser` shape (nullable `id`) usable.
659
+ ///
660
+ /// External review, finding 2 (Serious) — checked, and safe as written.
661
+ /// The Android sibling had a real defect here: `ReadableMap.getString`
662
+ /// THROWS on a non-string value, `txGuardVoid` swallowed the throw before
663
+ /// `Everframe.setUser` was reached, and `setUser({ id: 12345 })` therefore
664
+ /// left the PREVIOUS account installed. The conditional casts below cannot
665
+ /// do that — `NSDictionary` subscripting doesn't throw and `as? String`
666
+ /// yields `nil` for a number/dictionary/array — so a bad field degrades to
667
+ /// a partial (or empty) user and `Everframe.shared.setUser` is ALWAYS
668
+ /// reached, which is what makes the call replace rather than preserve. The
669
+ /// JS facade projects to these three string fields before the bridge
670
+ /// anyway (`src/user-projection.ts`); this is the second layer, aligned in
671
+ /// behaviour with Android's `stringOrNull` helper.
672
+ @objc public static func setUser(_ user: NSDictionary?) {
673
+ guard let user else {
674
+ Everframe.shared.setUser(nil)
675
+ return
676
+ }
677
+ Everframe.shared.setUser(EFUser(
678
+ id: user["id"] as? String,
679
+ email: user["email"] as? String,
680
+ displayName: user["displayName"] as? String
681
+ ))
682
+ }
683
+
684
+ // MARK: - recordScreen (screen markers — spec 2026-07-14)
685
+
686
+ /// Navigation screen marker. Forwards straight to
687
+ /// `Everframe.shared.recordScreen(_:data:)` — NO validation/coercion
688
+ /// here (blank-name drop, from→to derivation, gating, and data coercion
689
+ /// all live in the core singleton). Fail-soft on a non-dictionary
690
+ /// payload, same as `addBreadcrumb`.
691
+ @objc public static func recordScreen(
692
+ _ name: NSString,
693
+ data: NSDictionary?
694
+ ) {
695
+ Everframe.shared.recordScreen(
696
+ name as String,
697
+ data: data as? [String: Any]
698
+ )
699
+ }
700
+
701
+ // MARK: - Session Vitals (spec 2026-09-06)
702
+ //
703
+ // NOT HERE. The five vitals shims and their `RemotePlayerRegistry` used to live on this
704
+ // class as `static` funcs over a `private static let` registry — one map for the whole
705
+ // PROCESS. Codex round-1, C4 moved them to `RemoteVitalsBridge`, one instance per
706
+ // `EverframeModule` instance, because React Native does not guarantee one module
707
+ // instance: a process-wide registry made two React instances share a token space (the
708
+ // second instance's `rp1` was silently refused) and made either one's `invalidate`
709
+ // tear down the OTHER's players. See RemoteVitalsBridge.swift for the full rationale;
710
+ // Android's module has always owned its registry as an instance field, and this is now
711
+ // the same shape.
712
+
713
+ @objc(markSensitiveViewOrWarn:)
714
+ public static func markSensitiveViewOrWarn(_ view: NSObject?) {
715
+ dispatchPrecondition(condition: .onQueue(.main))
716
+ guard let v = view else {
717
+ os_log("[everframe] markSensitive: view not found for tag (registry no-op)",
718
+ log: log, type: .info)
719
+ return
720
+ }
721
+ markSensitive(v)
722
+ }
723
+
724
+ // MARK: - Companion (Plan 06.2-11)
725
+ //
726
+ // Bridge-side ownership of:
727
+ // • At most ONE live RelayWSClient against the configured ingest endpoint.
728
+ // • Combine subscriptions on `Everframe.shared.companion.$state`,
729
+ // `.$pairUrl`, `.$code` and `.$attachedUserName` that forward into
730
+ // `EverframeEventEmitter`.
731
+ //
732
+ // The Combine subscriptions are installed lazily on first `startCompanion`
733
+ // and kept across stop/start cycles (the companion observable survives the
734
+ // WS lifetime — see CompanionAPI.swift). This avoids missing the initial
735
+ // values that may already be present from a prior start.
736
+ //
737
+ // Concurrency: a single NSLock guards `companionClient` + `companionCancellables`
738
+ // mirrors the RelayWSClient internal locking pattern. NEVER called from
739
+ // RN's JS thread directly — the .mm shim hops to MainActor below to satisfy
740
+ // both `Everframe.shared.start` semantics and the RelayWSClient init/teardown
741
+ // contract.
742
+
743
+ private static let companionLock = NSLock()
744
+ nonisolated(unsafe) private static var companionClient: RelayWSClient?
745
+ /// The host's configured attach-PIN UI mode (spec 2026-08-19), parsed
746
+ /// from `ConfigOpts.attachPinUi` at `configure()` time. Defaults to
747
+ /// `.builtin` — the pre-existing-behaviour default for a host that never
748
+ /// set the flag (including every host on an older RN SDK version whose
749
+ /// `configure()` call carries no such field at all).
750
+ nonisolated(unsafe) private static var companionAttachPinUi: AttachPinUi = .builtin
751
+ // `CompanionCaptureBridge` subscribes to the report.request notification
752
+ // posted by RelayWSClient and runs the capture+send dance. Without an
753
+ // instance alive, report.request fires into the void → phone hangs on
754
+ // "Capturing the TV screen" forever (Plan 06.2-11 bugfix).
755
+ nonisolated(unsafe) private static var companionCaptureBridge: CompanionCaptureBridge?
756
+ nonisolated(unsafe) private static var companionCancellables: Set<AnyCancellable> = []
757
+ nonisolated(unsafe) private static var companionSubscriptionsInstalled = false
758
+
759
+ /// Open a relay WS connection and start forwarding
760
+ /// `Everframe.shared.companion` signals to the JS event emitter. Idempotent:
761
+ /// if a client is already live this is a no-op — the socket is NOT
762
+ /// recreated. Call `stopCompanion()` first to tear the existing connection
763
+ /// down before a subsequent `startCompanion()` opens a fresh one.
764
+ @objc public static func startCompanion(_ endpoint: NSString) {
765
+ // The JS-side `endpoint` arg is ignored — RelayWSClient defaults
766
+ // to the build-time-baked `IngestEndpoint.url`.
767
+ _ = endpoint
768
+
769
+ // MUST run before `RelayWSClient` is constructed below — see
770
+ // `ensurePinPresenterInstalledSync()`'s doc comment for the race this
771
+ // closes (finding 3, spec 2026-08-19 review).
772
+ ensurePinPresenterInstalledSync()
773
+
774
+ // Combine subscriptions install once and survive stop/start cycles;
775
+ // safe to (re-)call on every start (self-guarded). MUST run OUTSIDE
776
+ // `companionLock` — it self-acquires the same non-reentrant lock.
777
+ installCompanionSubscriptionsIfNeeded()
778
+
779
+ companionLock.lock()
780
+ // Idempotent: a live client means a prior startCompanion already
781
+ // opened the socket. Do NOT recreate it — callers must stopCompanion()
782
+ // first. Holding the lock across the check + install keeps two racing
783
+ // starts from both constructing a client (belt-and-suspenders; the
784
+ // .mm shim already serialises this on MainActor).
785
+ if companionClient != nil {
786
+ companionLock.unlock()
787
+ os_log("[everframe] startCompanion ignored — already running; call stopCompanion() first",
788
+ log: log, type: .info)
789
+ return
790
+ }
791
+ os_log("[everframe] startCompanion (endpoint arg ignored)",
792
+ log: log, type: .info)
793
+ // Runtime suppression of the built-in PIN presenter (spec 2026-08-19,
794
+ // controller ruling): a runtime flag, not an install-site gate.
795
+ // `EFReporterPresenter.installResolver()` always installs
796
+ // `CompanionPinPresenter` (see `installPresenterResolverIfNeeded()`
797
+ // above) so `__builtinPinUiInstalled` stays an honest capability
798
+ // signal regardless of the host's chosen mode — only the PRESENTING
799
+ // is silenced, here, at the one site where the mode is already known
800
+ // (configure() precedes startCompanion() in every host's lifecycle).
801
+ Everframe.shared.companion.__setBuiltinPinUiSuppressed(companionAttachPinUi != .builtin)
802
+ // Companion discovery (spec 2026-08-07): announcing is what makes this
803
+ // device appear in the dashboard's Companion tab. It is opt-in on the
804
+ // SDK key the host ALREADY configured via `configure()` — we never ask
805
+ // for a second one. A nil key takes the ticketless path: RelayWSClient
806
+ // builds no announcer and connects to plain `/relay/tv`, byte-identical
807
+ // to pre-companion behaviour. Announce must never be able to break
808
+ // reporting — no key means "no dashboard presence", never "no socket".
809
+ let client = RelayWSClient(
810
+ companion: Everframe.shared.companion,
811
+ sdkKey: configuredSdkKey(),
812
+ deviceLabel: companionDeviceLabel(),
813
+ attachPinUi: companionAttachPinUi,
814
+ // Naming spec 2026-08-24 — read off the same `currentConfig`
815
+ // snapshot `configuredSdkKey()` above already reads, not a
816
+ // second configuration surface. nil falls through to
817
+ // RelayWSClient's own Keychain-then-stored-UUID chain.
818
+ companionDeviceId: Everframe.shared.currentConfig?.companionDeviceId,
819
+ // Companion on-screen name badge (naming spec 2026-08-24,
820
+ // controller ruling / Task 6b) — same `currentConfig` snapshot,
821
+ // read fresh at start time (a mode/toggle can't affect anything
822
+ // before a relay client and its badge exist). `?? true` covers
823
+ // the same "never configured" edge `configuredSdkKey()`'s own
824
+ // optional chain already tolerates.
825
+ companionBadge: CompanionBadgeOptions(
826
+ enabled: Everframe.shared.currentConfig?.companionBadgeEnabled ?? true,
827
+ position: Self.parseCompanionBadgePosition(Everframe.shared.currentConfig?.companionBadgePosition)
828
+ )
829
+ )
830
+ // Wire the capture bridge BEFORE connecting so the report.request
831
+ // notification observer is registered before the first frame can fire.
832
+ let captureBridge = CompanionCaptureBridge(client: client)
833
+ companionClient = client
834
+ companionCaptureBridge = captureBridge
835
+ companionLock.unlock()
836
+
837
+ client.connect()
838
+ }
839
+
840
+ /// The SDK key the host already configured through `configure()`, or nil
841
+ /// when there isn't a usable one. Read straight off `Everframe.shared`'s
842
+ /// own config snapshot — deliberately NOT a second place a key can be
843
+ /// configured, and NOT a new public surface (`currentConfig` is already
844
+ /// public and read-only).
845
+ ///
846
+ /// Returns nil when `Everframe.start` never ran, and when the host
847
+ /// configured a blank/whitespace-only key. Both are "no dashboard
848
+ /// presence", never "no socket": RelayWSClient skips the announce hop
849
+ /// entirely on a nil key and reporting is unaffected.
850
+ private static func configuredSdkKey() -> String? {
851
+ guard let appId = Everframe.shared.currentConfig?.appId,
852
+ !appId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
853
+ else { return nil }
854
+ return appId
855
+ }
856
+
857
+ /// Non-personal device label for the dashboard's device list — "Apple TV",
858
+ /// "iPhone", "iPad". Deliberately `UIDevice.current.model` and NOT
859
+ /// `UIDevice.current.name`, which is commonly a person's name
860
+ /// ("Aurimas's Apple TV") and would leak into a shared dashboard list as a
861
+ /// side effect of turning companion on. The display code is the real
862
+ /// disambiguator; the label is only a convenience.
863
+ private static func companionDeviceLabel() -> String? {
864
+ let model = UIDevice.current.model
865
+ return model.isEmpty ? nil : model
866
+ }
867
+
868
+ /// Test-only — object identity of the live companion client, so tests can
869
+ /// prove a repeat `startCompanion()` is a no-op (same instance, not
870
+ /// recreated). Nil when no client is live.
871
+ static func _companionClientIdentityForTesting() -> ObjectIdentifier? {
872
+ companionLock.lock()
873
+ defer { companionLock.unlock() }
874
+ return companionClient.map(ObjectIdentifier.init)
875
+ }
876
+
877
+ /// JS handshake: called from the RN-bridge ObjC shim once the JS-side
878
+ /// `everframe.companion.reportRequested` listener is ready for capture to
879
+ /// proceed. Releases the CompanionCaptureBridge's bounded wait.
880
+ @objc public static func signalCompanionReportRequestReady(_ correlationId: NSString) {
881
+ CompanionCaptureBridge.signalReactTreeAttached(correlationId: correlationId as String)
882
+ }
883
+
884
+ /// Disconnect the active companion WS client (if any) and drop the
885
+ /// reference. Combine subscriptions stay alive across stop/start cycles
886
+ /// because the underlying CompanionAPI singleton on `Everframe.shared`
887
+ /// outlives the WS — replaying any state changes that happen between
888
+ /// stop and the next start would otherwise be lost from JS's view.
889
+ @objc public static func stopCompanion() {
890
+ os_log("[everframe] stopCompanion", log: log, type: .info)
891
+ companionLock.lock()
892
+ let client = companionClient
893
+ companionClient = nil
894
+ // Drop the capture bridge so its NotificationCenter observers
895
+ // deregister (CompanionCaptureBridge cleans up in deinit).
896
+ companionCaptureBridge = nil
897
+ companionLock.unlock()
898
+ client?.disconnect()
899
+ }
900
+
901
+ /// Install Combine sinks on `Everframe.shared.companion.$state`,
902
+ /// `.$pairUrl`, `.$code` and `.$attachedUserName` that forward to
903
+ /// `EverframeEventEmitter`. Idempotent — the
904
+ /// `companionSubscriptionsInstalled` flag guards re-entry.
905
+ ///
906
+ /// `@Published` publishers fire `.sink` synchronously on the writer's
907
+ /// thread (URLSession delegate queue, our `__setState` callsite). The
908
+ /// emitter's `sendEvent(withName:body:)` is thread-safe (RN bridge
909
+ /// enqueues onto the JS thread), so no main-thread hop is needed.
910
+ private static func installCompanionSubscriptionsIfNeeded() {
911
+ companionLock.lock()
912
+ let alreadyInstalled = companionSubscriptionsInstalled
913
+ companionSubscriptionsInstalled = true
914
+ companionLock.unlock()
915
+ guard !alreadyInstalled else { return }
916
+
917
+ let companion = Everframe.shared.companion
918
+ var bag = Set<AnyCancellable>()
919
+ companion.$state
920
+ .sink { state in
921
+ // CompanionState.rawValue is camelCase (Swift's default for
922
+ // String-backed enums) — `.reportInProgress` would cross to
923
+ // JS as "reportInProgress" and silently fail the union match
924
+ // in `src/companion.ts` (CompanionState = 'report_in_progress'
925
+ // | ...). Map to snake_case at the bridge boundary so both
926
+ // platforms emit the same JS-facing strings. Android does the
927
+ // equivalent via `CompanionState.toRnString()` in
928
+ // EverframeModule.kt.
929
+ EverframeEventEmitter.sendState(Self.rnString(for: state))
930
+ }
931
+ .store(in: &bag)
932
+ companion.$pairUrl
933
+ .sink { url in
934
+ EverframeEventEmitter.sendPairUrl(url)
935
+ }
936
+ .store(in: &bag)
937
+
938
+ // Companion discovery (spec 2026-08-07). `CompanionState.swift` also
939
+ // posts `.everframeCompanionCodeChange` /
940
+ // `.everframeCompanionAttachedUserNameChange` for UIKit/ObjC hosts, but
941
+ // we take the `@Published` route here for the same reason `$pairUrl`
942
+ // does: a `Published.Publisher` replays its CURRENT value to a new
943
+ // subscriber, so JS still learns a code that was already set by a
944
+ // prior start. NotificationCenter has no replay and posts only on a
945
+ // real change — subscribing there would silently lose exactly the
946
+ // initial values this install-once-and-survive-stop/start design
947
+ // exists to preserve.
948
+ companion.$code
949
+ .sink { code in
950
+ EverframeEventEmitter.sendCode(code)
951
+ }
952
+ .store(in: &bag)
953
+ companion.$attachedUserName
954
+ .sink { name in
955
+ EverframeEventEmitter.sendAttachedUserName(name)
956
+ }
957
+ .store(in: &bag)
958
+
959
+ // Resolved device display name (naming spec 2026-08-24). Same
960
+ // replay-on-subscribe rationale as `$code`/`$attachedUserName`
961
+ // above — a `Published` publisher replays its CURRENT value to a
962
+ // new subscriber, so JS still learns a name that was already
963
+ // resolved by a prior announce.
964
+ companion.$resolvedName
965
+ .sink { name in
966
+ EverframeEventEmitter.sendResolvedName(name)
967
+ }
968
+ .store(in: &bag)
969
+
970
+ // Attach-PIN challenge (spec 2026-08-19). Same replay-on-subscribe
971
+ // rationale as `$code`/`$attachedUserName` above — a `Published`
972
+ // publisher replays its CURRENT value to a new subscriber, so a
973
+ // `'custom'`-mode host that starts JS-side listening after the
974
+ // challenge already arrived still learns about it.
975
+ companion.$attachChallenge
976
+ .sink { challenge in
977
+ EverframeEventEmitter.sendAttachChallenge(challenge.map {
978
+ ["code": $0.code, "requestedByName": $0.requestedByName, "ttlMs": $0.ttlMs]
979
+ })
980
+ }
981
+ .store(in: &bag)
982
+
983
+ // Forward `report.request` arrival to JS so the companion module
984
+ // can acknowledge before the bridge proceeds with native capture
985
+ // (see CompanionCaptureBridge.awaitJsReactTreeAttach). The
986
+ // CompanionCaptureBridge posts this notification at the top of
987
+ // handleReportRequest and then awaits the JS callback up to
988
+ // 250ms before falling through.
989
+ NotificationCenter.default.publisher(
990
+ for: CompanionCaptureBridge.shouldAttachReactTreeNotification
991
+ )
992
+ .sink { note in
993
+ guard let corrId = note.userInfo?["correlation_id"] as? String else { return }
994
+ EverframeEventEmitter.sendReportRequested(corrId)
995
+ }
996
+ .store(in: &bag)
997
+
998
+ companionLock.lock()
999
+ companionCancellables = bag
1000
+ companionLock.unlock()
1001
+ os_log("[everframe] installed companion Combine subscriptions",
1002
+ log: log, type: .info)
1003
+ }
1004
+
1005
+ /// Map `CompanionState` to the snake_case payload string the JS facade
1006
+ /// expects (`'unpaired' | 'paired' | 'report_in_progress' |
1007
+ /// 'phone_disconnected'`). MUST match `EverframeModule.toRnString()` on
1008
+ /// Android. Not `@objc` — `CompanionState` is a Swift enum that does not
1009
+ /// bridge to ObjC; consumers call this from Swift only (the .mm shim sends
1010
+ /// already-stringified payloads to the event emitter).
1011
+ public static func rnString(for state: CompanionState) -> String {
1012
+ switch state {
1013
+ case .unpaired: return "unpaired"
1014
+ case .paired: return "paired"
1015
+ case .reportInProgress: return "report_in_progress"
1016
+ case .phoneDisconnected: return "phone_disconnected"
1017
+ }
1018
+ }
1019
+
1020
+ // MARK: - Test seams (internal access via @testable import)
1021
+
1022
+ /// Whether a RelayWSClient is currently owned by the bridge. The test
1023
+ /// target uses this to assert that `startCompanion(...)` installs the
1024
+ /// client and `stopCompanion()` drops it.
1025
+ @objc public static func _hasCompanionClientForTesting() -> Bool {
1026
+ companionLock.lock()
1027
+ defer { companionLock.unlock() }
1028
+ return companionClient != nil
1029
+ }
1030
+
1031
+ /// Test-only reset for the Combine subscription latch + cancellables —
1032
+ /// XCTest creates a fresh bridge state per test method.
1033
+ @objc public static func _resetCompanionForTesting() {
1034
+ companionLock.lock()
1035
+ companionClient?.disconnect()
1036
+ companionClient = nil
1037
+ companionCancellables.removeAll()
1038
+ companionSubscriptionsInstalled = false
1039
+ companionAttachPinUi = .builtin
1040
+ companionLock.unlock()
1041
+ Everframe.shared.companion.__setBuiltinPinUiSuppressed(false)
1042
+ }
1043
+ }