@everframe/react-native 1.2.1 → 1.4.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.
@@ -30,6 +30,7 @@ import CoreFoundation
30
30
  import Combine
31
31
  import os.log
32
32
  import UIKit
33
+ import EverframeProtocol
33
34
  import EverframeKit
34
35
  import EverframeReporterUI // tvOS slice present per Package.swift:12 (TV-03 of Phase 06.1)
35
36
 
@@ -68,7 +69,10 @@ import EverframeReporterUI // tvOS slice present per Package.swift:12 (TV-03
68
69
  releaseHealthLoadedBuildId: NSString?,
69
70
  releaseHealthUserId: NSString?,
70
71
  releaseHealthIncludeDebugBuilds: Bool,
71
- reporterDisabled: Bool = false
72
+ jsBundleBuildId: NSString? = nil,
73
+ jsBundleName: NSString? = nil,
74
+ reporterDisabled: Bool = false,
75
+ interactionsJson: NSString? = nil
72
76
  ) throws {
73
77
  configureLock.lock()
74
78
  defer { configureLock.unlock() }
@@ -80,6 +84,8 @@ import EverframeReporterUI // tvOS slice present per Package.swift:12 (TV-03
80
84
  // Polarity flips exactly once, here: JS sends the veto
81
85
  // (`networkBodiesDisabled`), native stores the permission.
82
86
  var cfg = EverframeConfig(sdkKey: sdkKey as String)
87
+ cfg.interactions = InteractionEventPolicy.parse(interactionsJson as String?)
88
+ cfg.loadedJsBundle = LoadedJsBundle(buildId: jsBundleBuildId as String?, bundleName: jsBundleName as String?)
83
89
  cfg.capture.networkBodies = !networkBodiesDisabled
84
90
  // Polarity flips exactly once, here: JS sends the veto
85
91
  // (`installIdentifierDisabled`), native stores the permission —
@@ -537,6 +543,11 @@ import EverframeReporterUI // tvOS slice present per Package.swift:12 (TV-03
537
543
  @objc public static func openReporter(
538
544
  completion: @escaping (NSDictionary?, NSError?) -> Void
539
545
  ) {
546
+ openReporter(trigger: "button", completion: completion)
547
+ }
548
+
549
+ @objc(openReporterWithTrigger:completion:)
550
+ public static func openReporter(trigger: String, completion: @escaping (NSDictionary?, NSError?) -> Void) {
540
551
  os_log("[everframe] openReporter.enter", log: log, type: .info)
541
552
  guard Everframe.shared.captureGate else {
542
553
  completion(nil, NSError(
@@ -553,7 +564,7 @@ import EverframeReporterUI // tvOS slice present per Package.swift:12 (TV-03
553
564
 
554
565
  Task {
555
566
  do {
556
- let result = try await Everframe.shared.report.open()
567
+ let result = try await Everframe.shared.report.open(trigger: ReporterTrigger(rawValue: trigger) ?? .button)
557
568
  let dict: NSDictionary
558
569
  switch result {
559
570
  case .submitted(let id):
@@ -575,6 +586,15 @@ import EverframeReporterUI // tvOS slice present per Package.swift:12 (TV-03
575
586
  }
576
587
  }
577
588
 
589
+ @objc public static func markReporterOpened(_ trigger: String, completion: @escaping @Sendable () -> Void) {
590
+ guard let trigger = ReporterTrigger(rawValue: trigger) else { completion(); return }
591
+ BreadcrumbRingBuffer.shared.reporterOpened(trigger: trigger, completion: completion)
592
+ }
593
+
594
+ @objc public static func markReporterClosed(_ completion: @escaping @Sendable () -> Void) {
595
+ BreadcrumbRingBuffer.shared.reporterClosed(completion: completion)
596
+ }
597
+
578
598
  /// `openReporter`'s error for a crash-only session (`reporterDisabled: true`).
579
599
  static let reporterDisabledErrorCode = 1101
580
600
 
@@ -712,6 +732,13 @@ import EverframeReporterUI // tvOS slice present per Package.swift:12 (TV-03
712
732
  /// JS facade projects to these three string fields before the bridge
713
733
  /// anyway (`src/user-projection.ts`); this is the second layer, aligned in
714
734
  /// behaviour with Android's `stringOrNull` helper.
735
+ @objc public static func setStateSync(_ key: String, valueJson: String) -> Bool {
736
+ guard valueJson.utf8.count <= 2048, let data = valueJson.data(using: .utf8),
737
+ let value = try? JSONDecoder().decode(EverframeStateValue.self, from: data) else { return false }
738
+ return Everframe.shared.setState(key, value: value)
739
+ }
740
+ @objc public static func clearStateSync(_ key: String) -> Bool { Everframe.shared.clearState(key) }
741
+
715
742
  @objc public static func setUser(_ user: NSDictionary?) {
716
743
  guard let user else {
717
744
  Everframe.shared.setUser(nil)
@@ -63,6 +63,48 @@
63
63
  #import "EverframeRN-Swift.h"
64
64
  #endif
65
65
 
66
+ #import <React/RCTViewComponentView.h>
67
+ #import <react/renderer/components/view/ViewProps.h>
68
+ #import <react/renderer/components/view/accessibilityPropsConversions.h>
69
+ #import <objc/runtime.h>
70
+ #import "RnDevtoolsOrigin.h"
71
+
72
+ // Fabric clears props and reuses component views. Invalidate before reuse begins.
73
+ @interface RCTViewComponentView (EverframeInteractionTarget)
74
+ @end
75
+ @implementation RCTViewComponentView (EverframeInteractionTarget)
76
+ - (void)ef_target_prepareForRecycle {
77
+ [RnInteractionTargetRegistration invalidateView:self];
78
+ [RnInteractionEventRegistration invalidateView:self];
79
+ [self ef_target_prepareForRecycle];
80
+ }
81
+ @end
82
+
83
+ // Fabric exposes the native TextInput/Switch accessibility element publicly.
84
+ // ScrollView's public scrollView getter is resolved by the registration layer.
85
+ // Never reach into component-private ivars or replace their delegates.
86
+ static UIView *EFInteractionHost(UIView *view) {
87
+ if ([view isKindOfClass:RCTViewComponentView.class]) {
88
+ NSObject *element = [(RCTViewComponentView *)view accessibilityElement];
89
+ if ([element isKindOfClass:UITextField.class] || [element isKindOfClass:UITextView.class]
90
+ #if !TARGET_OS_TV
91
+ || [element isKindOfClass:UISwitch.class] || [element isKindOfClass:UISlider.class]
92
+ #endif
93
+ || [element isKindOfClass:UIScrollView.class]) return (UIView *)element;
94
+ }
95
+ return view;
96
+ }
97
+
98
+ static NSDictionary *EFInteractionHints(UIView *view) {
99
+ if (![view isKindOfClass:RCTViewComponentView.class]) return nil;
100
+ const auto props = std::static_pointer_cast<const facebook::react::ViewProps>([(RCTViewComponentView *)view props]);
101
+ if (!props) return nil;
102
+ const std::string role = props->role != facebook::react::Role::None
103
+ ? facebook::react::toString(props->role) : props->accessibilityRole;
104
+ return @{ @"testId": @(props->testId.c_str()), @"role": @(role.c_str()), @"name": @(props->accessibilityLabel.c_str()),
105
+ @"actionable": @(props->events[facebook::react::ViewEvents::Offset::Click]) };
106
+ }
107
+
66
108
  @implementation EverframeModule {
67
109
  // Strong ref to the bridge so we can look up RCTUIManager later.
68
110
  __weak RCTBridge *_bridge;
@@ -73,6 +115,8 @@
73
115
  // and let either one's `-invalidate` tear down the other's players. Android's
74
116
  // module has always held its registry as an instance field; this matches it.
75
117
  RemoteVitalsBridge *_vitals;
118
+ RnInteractionTargetRegistration *_interactionTargets;
119
+ RnInteractionEventRegistration *_interactionEvents;
76
120
  // D1 fix (stale-teardown identity guard, spec 2026-09-17 setExtra-resolver).
77
121
  // Snapshot of `[EverframeBridge currentExtraResolveHookGeneration]` taken
78
122
  // right after THIS instance's most recent `-configureSync:` call — see
@@ -84,6 +128,8 @@
84
128
  NSInteger _extraResolveHookGeneration;
85
129
  }
86
130
 
131
+ @synthesize viewRegistry_DEPRECATED = _viewRegistry_DEPRECATED;
132
+
87
133
  RCT_EXPORT_MODULE(Everframe)
88
134
 
89
135
  - (instancetype)init {
@@ -91,6 +137,16 @@ RCT_EXPORT_MODULE(Everframe)
91
137
  // Cheap: the registry allocates one lock and an empty map, nothing more —
92
138
  // which is also why `+requiresMainQueueSetup` can stay NO.
93
139
  _vitals = [RemoteVitalsBridge new];
140
+ _interactionEvents = [RnInteractionEventRegistration new];
141
+ _interactionTargets = [RnInteractionTargetRegistration new];
142
+ [_interactionTargets installOriginAdapter:^BOOL(UIView *view) { return EFIsDevMenuView(view); }];
143
+ [_interactionTargets installAdapter:^NSDictionary *(UIView *view) { return EFInteractionHints(view); }];
144
+ static dispatch_once_t once;
145
+ dispatch_once(&once, ^{
146
+ EFInstallDevMenuOrigin();
147
+ method_exchangeImplementations(class_getInstanceMethod(RCTViewComponentView.class, @selector(prepareForRecycle)),
148
+ class_getInstanceMethod(RCTViewComponentView.class, @selector(ef_target_prepareForRecycle)));
149
+ });
94
150
  }
95
151
  return self;
96
152
  }
@@ -211,7 +267,10 @@ RCT_EXPORT_MODULE(Everframe)
211
267
  releaseHealthLoadedBuildId:opts.releaseHealthLoadedBuildId()
212
268
  releaseHealthUserId:opts.releaseHealthUserId()
213
269
  releaseHealthIncludeDebugBuilds:opts.releaseHealthIncludeDebugBuilds().value_or(false)
270
+ jsBundleBuildId:opts.jsBundleBuildId()
271
+ jsBundleName:opts.jsBundleName()
214
272
  reporterDisabled:reporterDisabled
273
+ interactionsJson:opts.interactionsJson()
215
274
  error:&err];
216
275
  if (err != nil) {
217
276
  // A refused configuration is logged; JS keeps its mount either way.
@@ -223,6 +282,7 @@ RCT_EXPORT_MODULE(Everframe)
223
282
  // hand back to `resetExtraResolverForTeardown:`. See that method's doc
224
283
  // comment on EverframeBridge.swift for the race this closes.
225
284
  _extraResolveHookGeneration = [EverframeBridge currentExtraResolveHookGeneration];
285
+ if (err == nil) [_interactionTargets onStarted];
226
286
  // Codex round-5 — nothing re-registers the live players here any more. `renewAll()` raced
227
287
  // the runtime's deferred-registration drain (it could detach the replacement it had just
228
288
  // installed) and only ever reached THIS module instance's registry. The replacement is two
@@ -250,6 +310,50 @@ RCT_EXPORT_MODULE(Everframe)
250
310
  }];
251
311
  }
252
312
 
313
+ - (void)openReporterWithTrigger:(NSString *)trigger resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
314
+ [EverframeBridge openReporterWithTrigger:trigger completion:^(NSDictionary *result, NSError *err) {
315
+ if (err) reject([EverframeBridge openReporterRejectCodeFor:err], err.localizedDescription, err);
316
+ else resolve(result ?: @{});
317
+ }];
318
+ }
319
+ - (void)markReporterOpened:(NSString *)trigger resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
320
+ [EverframeBridge markReporterOpened:trigger completion:^{ resolve(nil); }];
321
+ }
322
+ - (void)markReporterClosed:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
323
+ [EverframeBridge markReporterClosed:^{ resolve(nil); }];
324
+ }
325
+
326
+ - (NSNumber *)setInteractionTarget:(double)viewTag
327
+ registrationId:(NSString *)registrationId
328
+ generation:(double)generation
329
+ annotation:(NSDictionary *)annotation {
330
+ __weak RCTViewRegistry *registry = _viewRegistry_DEPRECATED;
331
+ return @([_interactionTargets setTarget:viewTag registrationId:registrationId generation:generation annotation:annotation
332
+ resolve:^UIView *(NSNumber *tag) {
333
+ @try { return [registry viewForReactTag:tag]; } @catch (NSException *exception) { return nil; }
334
+ }]);
335
+ }
336
+
337
+ - (NSNumber *)clearInteractionTarget:(NSString *)registrationId generation:(double)generation {
338
+ return @([_interactionTargets clearTarget:registrationId generation:generation]);
339
+ }
340
+
341
+ - (NSString *)getInteractionLease { return [_interactionEvents lease]; }
342
+ - (NSNumber *)setInteractionObserver:(double)viewTag id:(NSString *)identifier generation:(double)generation
343
+ lease:(NSString *)lease optionsJson:(NSString *)optionsJson {
344
+ __weak RCTViewRegistry *registry = _viewRegistry_DEPRECATED;
345
+ return @([_interactionEvents setObserver:viewTag id:identifier generation:generation lease:lease optionsJson:optionsJson
346
+ resolve:^UIView *(NSNumber *tag) {
347
+ @try { return [registry viewForReactTag:tag]; } @catch (NSException *exception) { return nil; }
348
+ } host:^UIView *(UIView *view) { return EFInteractionHost(view); }]);
349
+ }
350
+ - (NSNumber *)clearInteractionObserver:(NSString *)identifier generation:(double)generation {
351
+ return @([_interactionEvents clearObserver:identifier generation:generation]);
352
+ }
353
+ - (NSNumber *)recordInteraction:(NSString *)lease id:(NSString *)identifier generation:(double)generation eventJson:(NSString *)eventJson {
354
+ return @([_interactionEvents record:lease id:identifier generation:generation eventJson:eventJson]);
355
+ }
356
+
253
357
  // MARK: - registerSensitiveRect(viewTag, rect)
254
358
 
255
359
  // `viewTag` is `double` per RN codegen rules (numbers cross as double).
@@ -259,15 +363,11 @@ RCT_EXPORT_MODULE(Everframe)
259
363
  // available if a future iteration needs hint-based registration.
260
364
  - (void)registerSensitiveRect:(double)viewTag
261
365
  rect:(JS::NativeEverframe::Rect &)rect {
262
- __weak RCTBridge *weakBridge = _bridge;
366
+ __weak RCTViewRegistry *registry = _viewRegistry_DEPRECATED;
263
367
  NSNumber *tag = @((NSInteger)viewTag);
264
368
  dispatch_async(dispatch_get_main_queue(), ^{
265
- RCTBridge *strongBridge = weakBridge;
266
369
  UIView *view = nil;
267
- if (strongBridge != nil) {
268
- view = [strongBridge.uiManager viewForReactTag:tag];
269
- }
270
- // Bridge does a nil-check + warn-once (Pitfall P5 / T-06-02-03).
370
+ @try { view = [registry viewForReactTag:tag]; } @catch (NSException *exception) { }
271
371
  [EverframeBridge markSensitiveViewOrWarn:view];
272
372
  });
273
373
  }
@@ -395,6 +495,8 @@ RCT_EXPORT_MODULE(Everframe)
395
495
  // Provider's configure-on-mount, so clearing there would drop legitimate
396
496
  // registrations that had already landed.
397
497
  - (void)invalidate {
498
+ [_interactionEvents close];
499
+ [_interactionTargets close];
398
500
  [_vitals reset];
399
501
  // Review finding F4 (spec 2026-09-17 setExtra-resolver) — see
400
502
  // `EverframeBridge.resetExtraResolverForTeardown`'s doc comment for why
@@ -428,6 +530,13 @@ RCT_EXPORT_MODULE(Everframe)
428
530
  // shape; `id` is compatible with the generated `NSDictionary *` protocol
429
531
  // type. `EverframeBridge` re-validates and pulls out id/email/displayName
430
532
  // defensively.
533
+ - (NSNumber *)setStateSync:(NSString *)key valueJson:(NSString *)valueJson {
534
+ return @([EverframeBridge setStateSync:key valueJson:valueJson]);
535
+ }
536
+ - (NSNumber *)clearStateSync:(NSString *)key {
537
+ return @([EverframeBridge clearStateSync:key]);
538
+ }
539
+
431
540
  - (void)setUser:(id)user {
432
541
  NSDictionary *dict = [user isKindOfClass:[NSDictionary class]] ? (NSDictionary *)user : nil;
433
542
  [EverframeBridge setUser:dict];
@@ -0,0 +1,7 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 ScriptX
3
+ #import <UIKit/UIKit.h>
4
+
5
+ /// Framework support only; all calls after installation run on the UI thread.
6
+ FOUNDATION_EXPORT void EFInstallDevMenuOrigin(void);
7
+ FOUNDATION_EXPORT BOOL EFIsDevMenuView(UIView *view);
@@ -0,0 +1,36 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 ScriptX
3
+ #import "RnDevtoolsOrigin.h"
4
+ #import <objc/runtime.h>
5
+ #import <cstring>
6
+
7
+ // RN's developer menu is a generic UIAlertController. Attribute the actual
8
+ // controller owned by RCTDevMenu, never its title or any of its button labels.
9
+ static NSHashTable<id> *EFDevMenus;
10
+ static Ivar EFDevMenuActionSheet;
11
+ void EFInstallDevMenuOrigin(void) {
12
+ Class cls = NSClassFromString(@"RCTDevMenu");
13
+ Method method = cls ? class_getInstanceMethod(cls, NSSelectorFromString(@"show")) : NULL;
14
+ Ivar actionSheet = cls ? class_getInstanceVariable(cls, "_actionSheet") : NULL;
15
+ if (!method || !actionSheet || strcmp(ivar_getTypeEncoding(actionSheet), "@\"UIAlertController\"") != 0) return;
16
+ EFDevMenus = [NSHashTable weakObjectsHashTable];
17
+ EFDevMenuActionSheet = actionSheet;
18
+ typedef void (*ShowFn)(id, SEL);
19
+ ShowFn original = (ShowFn)method_getImplementation(method);
20
+ method_setImplementation(method, imp_implementationWithBlock(^(id menu) {
21
+ original(menu, NSSelectorFromString(@"show"));
22
+ if (![NSThread isMainThread]) return;
23
+ id controller = object_getIvar(menu, actionSheet);
24
+ if ([controller isKindOfClass:UIViewController.class]) [EFDevMenus addObject:menu];
25
+ }));
26
+ }
27
+ BOOL EFIsDevMenuView(UIView *view) {
28
+ for (id menu in EFDevMenus) {
29
+ id current = object_getIvar(menu, EFDevMenuActionSheet);
30
+ if (![current isKindOfClass:UIViewController.class]) continue;
31
+ UIViewController *controller = current;
32
+ if (controller.isViewLoaded && [view isDescendantOfView:controller.view]) return YES;
33
+ }
34
+ return NO;
35
+ }
36
+
@@ -0,0 +1,141 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 ScriptX
3
+ import UIKit
4
+ import EverframeKit
5
+
6
+ /// Only committed refs acquire ownership; neither a configure nor a delayed callback renews it.
7
+ @objc public final class RnInteractionEventRegistration: NSObject {
8
+ private final class Request {
9
+ let tag: NSNumber, generation: UInt64, lease: String, types: Set<String>
10
+ let resolve: (NSNumber) -> UIView?
11
+ weak var root: UIView?
12
+ weak var host: UIView?
13
+ var resolved = false
14
+ init(_ tag: NSNumber, _ generation: UInt64, _ lease: String, _ types: Set<String>, _ resolve: @escaping (NSNumber) -> UIView?) {
15
+ self.tag = tag; self.generation = generation; self.lease = lease; self.types = types; self.resolve = resolve
16
+ }
17
+ }
18
+ private static let instancesLock = NSLock()
19
+ private static let instances = NSHashTable<RnInteractionEventRegistration>.weakObjects()
20
+ private let dispatchRecord: (@escaping @MainActor () -> Void) -> Void
21
+ public override convenience init() {
22
+ self.init(dispatchRecord: { DispatchQueue.main.async(execute: $0) })
23
+ }
24
+ init(dispatchRecord: @escaping (@escaping @MainActor () -> Void) -> Void) {
25
+ self.dispatchRecord = dispatchRecord
26
+ super.init()
27
+ Self.instancesLock.lock(); Self.instances.add(self); Self.instancesLock.unlock()
28
+ }
29
+ @MainActor @objc public static func invalidateView(_ view: UIView) {
30
+ instancesLock.lock(); let owners = instances.allObjects; instancesLock.unlock()
31
+ for owner in owners {
32
+ owner.lock.lock(); defer { owner.lock.unlock() }
33
+ let matches = owner.requests.filter { $0.value.root === view || (!$0.value.resolved && $0.value.tag.intValue == view.tag) }
34
+ for (id, request) in matches {
35
+ InteractionEvents.cancelRegistration(id: owner.key(id), generation: request.generation)
36
+ owner.requests.removeValue(forKey: id)
37
+ }
38
+ }
39
+ }
40
+ private let lock = NSRecursiveLock()
41
+ private let runtime = InteractionEvents.openRuntime()
42
+ private var requests: [String: Request] = [:]
43
+ private struct PendingRecord {
44
+ let item: PreparedInteraction
45
+ let id: String
46
+ let generation: UInt64
47
+ }
48
+ private var records: [PendingRecord] = []
49
+ private var recordDrainScheduled = false
50
+ private var closed = false
51
+ @objc public var active: Bool { lock.lock(); defer { lock.unlock() }; return !closed && InteractionEvents.ownsRuntime(runtime) }
52
+ @objc public func lease() -> String { active ? InteractionEvents.lease() ?? "" : "" }
53
+ private func key(_ id: String) -> String { runtime + ":" + id }
54
+ private func integer(_ n: Double, _ max: Double) -> Bool { n.isFinite && n >= 0 && n <= max && n.rounded() == n }
55
+ private func validId(_ id: String) -> Bool { !id.isEmpty && id.utf16.count <= 128 && !id.unicodeScalars.contains { $0.value < 32 || $0.value == 127 } }
56
+ @objc public func setObserver(_ tag: Double, id: String, generation: Double, lease token: String, optionsJson: String,
57
+ resolve: @escaping (NSNumber) -> UIView?, host: @escaping (UIView) -> UIView?) -> Bool {
58
+ lock.lock(); defer { lock.unlock() }
59
+ guard active, validId(id), tag > 0, integer(tag, Double(Int32.max)), integer(generation, 9_007_199_254_740_990),
60
+ !token.isEmpty, token == lease(), optionsJson.utf8.count <= 8192,
61
+ let data = optionsJson.data(using: .utf8), let options = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
62
+ let list = options["helperTypes"] as? [String], list.count <= 3, Set(list).isSubset(of: ["text_edit", "control_change", "scroll"]) else { return false }
63
+ requests = requests.filter { $0.value.lease == token }
64
+ guard requests.count < 4096 || requests[id] != nil,
65
+ InteractionEvents.beginRegistration(id: key(id), generation: UInt64(generation), lease: token, optionsJson: optionsJson) else { return false }
66
+ // JS callbacks cannot prove the native host was safe when observed.
67
+ // Keep metadata and lifetime tracking without suppressing native capture,
68
+ // including for older JS callers that still declare helper ownership.
69
+ let types: Set<String> = []
70
+ let request = Request(NSNumber(value: tag), UInt64(generation), token, types, resolve)
71
+ requests[id] = request; enqueue(id, request, host: host)
72
+ return true
73
+ }
74
+ private func enqueue(_ id: String, _ request: Request, host: @escaping (UIView) -> UIView?, attempt: Int = 0) {
75
+ DispatchQueue.main.async { [weak self] in
76
+ guard let self else { return }
77
+ self.lock.lock(); defer { self.lock.unlock() }
78
+ guard self.active, request.lease == self.lease(), self.requests[id] === request, !request.resolved else { return }
79
+ guard let root = request.resolve(request.tag), root.window != nil, root.tag == request.tag.intValue else {
80
+ if attempt < 8 {
81
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.016 * pow(2, Double(attempt))) { [weak self] in
82
+ self?.enqueue(id, request, host: host, attempt: attempt + 1)
83
+ }
84
+ }
85
+ return
86
+ }
87
+ guard var view = host(root) else { return }
88
+ // Fabric ScrollView exposes its native host through this public getter;
89
+ // its accessibility element is the wrapper, unlike TextInput/Switch.
90
+ if view.responds(to: NSSelectorFromString("scrollView")),
91
+ let scroll = view.value(forKey: "scrollView") as? UIScrollView { view = scroll }
92
+ guard view === root || view.isDescendant(of: root) else { return }
93
+ request.root = root; request.host = view; request.resolved = true
94
+ _ = InteractionEvents.completeRegistration(view: view, id: self.key(id), generation: request.generation, lease: request.lease, types: request.types)
95
+ }
96
+ }
97
+ @objc public func clearObserver(_ id: String, generation: Double) -> Bool {
98
+ lock.lock(); defer { lock.unlock() }
99
+ guard active, validId(id), integer(generation, 9_007_199_254_740_991),
100
+ InteractionEvents.clearRegistration(id: key(id), generation: UInt64(generation)) else { return false }
101
+ requests.removeValue(forKey: id); return true
102
+ }
103
+ @objc public func record(_ token: String, id: String, generation: Double, eventJson: String) -> Bool {
104
+ lock.lock(); defer { lock.unlock() }
105
+ guard active, integer(generation, 9_007_199_254_740_991), id.isEmpty || validId(id),
106
+ let item = InteractionEvents.prepare(lease: token, id: id.isEmpty ? "" : key(id), generation: UInt64(generation), json: eventJson) else { return false }
107
+ // Coalescing/revocation can empty cells before main runs. Bound those
108
+ // cells too and keep only one scheduled callback for this mailbox.
109
+ if records.count == 64 { InteractionEvents.discardPrepared(records.removeFirst().item) }
110
+ records.append(PendingRecord(item: item, id: id, generation: UInt64(generation)))
111
+ if !recordDrainScheduled { recordDrainScheduled = true; scheduleRecordDrain() }
112
+ return true
113
+ }
114
+ private func scheduleRecordDrain() {
115
+ dispatchRecord { [weak self] in self?.drainRecords() }
116
+ }
117
+ @MainActor private func drainRecords() {
118
+ // Yield after a bounded batch even if producers keep refilling the mailbox.
119
+ for _ in 0..<64 {
120
+ lock.lock(); defer { lock.unlock() }
121
+ guard !records.isEmpty else { break }
122
+ let pending = records.removeFirst()
123
+ defer { InteractionEvents.discardPrepared(pending.item) }
124
+ guard active else { continue }
125
+ if !pending.id.isEmpty {
126
+ guard let request = requests[pending.id], request.generation == pending.generation,
127
+ let root = request.root, request.resolve(request.tag) === root, root.window != nil,
128
+ let host = request.host, host === root || host.isDescendant(of: root) else { continue }
129
+ }
130
+ _ = InteractionEvents.recordPrepared(pending.item)
131
+ }
132
+ lock.lock(); defer { lock.unlock() }
133
+ if records.isEmpty { recordDrainScheduled = false } else { scheduleRecordDrain() }
134
+ }
135
+ @objc public func close() {
136
+ lock.lock(); defer { lock.unlock() }
137
+ closed = true
138
+ records.forEach { InteractionEvents.discardPrepared($0.item) }; records.removeAll()
139
+ requests.removeAll(); InteractionEvents.closeRuntime(runtime)
140
+ }
141
+ }
@@ -0,0 +1,107 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 ScriptX
3
+ import UIKit
4
+ import EverframeKit
5
+
6
+ /// Ref cleanup cancels ownership immediately; UIKit work is always queued on main.
7
+ @objc public final class RnInteractionTargetRegistration: NSObject {
8
+ private final class Request {
9
+ let tag: NSNumber, generation: UInt64, annotation: [String: Any]
10
+ let resolve: (NSNumber) -> UIView?
11
+ weak var view: UIView?
12
+ var resolved = false
13
+ init(_ tag: NSNumber, _ generation: UInt64, _ annotation: [String: Any], _ resolve: @escaping (NSNumber) -> UIView?) {
14
+ self.tag=tag; self.generation=generation; self.annotation=annotation; self.resolve=resolve
15
+ }
16
+ }
17
+ private let lock=NSRecursiveLock()
18
+ private let namespace=UUID().uuidString + ":"
19
+ private var requests: [String: Request]=[:]
20
+ private var destroyed=false
21
+ private var removeAdapter: (() -> Void)?
22
+ private var removeOriginAdapter: (() -> Void)?
23
+
24
+ @objc public func installOriginAdapter(_ read: @escaping (UIView) -> Bool) {
25
+ DispatchQueue.main.async { [weak self] in
26
+ guard let self else { return }
27
+ self.lock.lock(); defer { self.lock.unlock() }
28
+ guard !self.destroyed else { return }
29
+ self.removeOriginAdapter = InteractionOriginViewAdapters.register(read)
30
+ }
31
+ }
32
+
33
+ @objc public func installAdapter(_ read: @escaping (UIView) -> NSDictionary?) {
34
+ DispatchQueue.main.async { [weak self] in
35
+ guard let self else { return }
36
+ self.lock.lock(); defer { self.lock.unlock() }
37
+ guard !self.destroyed else { return }
38
+ self.removeAdapter=InteractionTargetViewAdapters.register { view in
39
+ guard let fields=read(view) else { return nil }
40
+ let raw=fields["role"] as? String
41
+ let role: String?
42
+ switch raw {
43
+ case "imagebutton": role="button"
44
+ case "adjustable": role="slider"
45
+ case "search", "searchbox", "textbox": role="textfield"
46
+ case "heading": role="header"
47
+ case "img": role="image"
48
+ case "none", "": role=nil
49
+ default: role=raw
50
+ }
51
+ return InteractionTargetViewHints(testId: fields["testId"] as? String, role:role, name:fields["name"] as? String ?? "",
52
+ actionable: (fields["actionable"] as? Bool ?? false) || (role.map { ["button","tab","link","switch","checkbox","radio","slider","menuitem","textfield"].contains($0) } ?? false))
53
+ }
54
+ }
55
+ }
56
+ private func integer(_ n: Double, _ maximum: Double) -> Bool { n.isFinite && n >= 0 && n <= maximum && n.rounded() == n }
57
+ @objc public func setTarget(_ tag: Double, registrationId id: String, generation: Double, annotation: [String: Any], resolve: @escaping (NSNumber) -> UIView?) -> Bool {
58
+ lock.lock(); defer { lock.unlock() }
59
+ guard !destroyed, tag > 0, integer(tag,Double(Int32.max)), integer(generation,9_007_199_254_740_990),
60
+ InteractionTargetRegistry.beginSet(registrationId:namespace+id,generation:UInt64(generation)) else { return false }
61
+ let request=Request(NSNumber(value:tag),UInt64(generation),annotation,resolve)
62
+ requests[id]=request; enqueue(id,request)
63
+ return true
64
+ }
65
+ private func enqueue(_ id: String, _ request: Request, attempt: Int = 0) {
66
+ DispatchQueue.main.async { [weak self] in
67
+ guard let self else { return }
68
+ self.lock.lock(); defer { self.lock.unlock() }
69
+ guard !self.destroyed, self.requests[id] === request else { return }
70
+ guard let view=request.resolve(request.tag), view.window != nil else {
71
+ // Fabric refs precede mounting. Bound retries, and re-check ownership
72
+ // before every lookup so cleanup cannot register a recycled host.
73
+ if !request.resolved, attempt < 8 {
74
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.016 * pow(2, Double(attempt))) { [weak self] in
75
+ self?.enqueue(id, request, attempt: attempt + 1)
76
+ }
77
+ }
78
+ return
79
+ }
80
+ guard !request.resolved || request.view === view else { return }
81
+ request.view=view; request.resolved=true
82
+ InteractionTargetRegistry.completeSet(view:view,registrationId:self.namespace+id,generation:request.generation,annotation:request.annotation)
83
+ }
84
+ }
85
+ @objc public func clearTarget(_ id: String, generation: Double) -> Bool {
86
+ lock.lock(); defer { lock.unlock() }
87
+ guard !destroyed, integer(generation,9_007_199_254_740_991) else { return false }
88
+ if requests[id].map({ UInt64(generation) > $0.generation }) ?? true { requests.removeValue(forKey:id) }
89
+ InteractionTargetRegistry.clear(registrationId:namespace+id,generation:UInt64(generation))
90
+ return true
91
+ }
92
+ @objc public func onStarted() {
93
+ lock.lock(); defer { lock.unlock() }
94
+ guard !destroyed else { return }
95
+ for (id, request) in requests {
96
+ if InteractionTargetRegistry.beginSet(registrationId:namespace+id,generation:request.generation) { enqueue(id,request) }
97
+ }
98
+ }
99
+ @objc public func close() {
100
+ lock.lock(); defer { lock.unlock() }
101
+ destroyed=true
102
+ for (id, request) in requests { InteractionTargetRegistry.clear(registrationId:namespace+id,generation:request.generation+1) }
103
+ requests.removeAll()
104
+ DispatchQueue.main.async { [self] in removeAdapter?(); removeAdapter=nil; removeOriginAdapter?(); removeOriginAdapter=nil }
105
+ }
106
+ @MainActor @objc public static func invalidateView(_ view: UIView) { InteractionTargetRegistry.invalidate(view:view) }
107
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everframe/react-native",
3
- "version": "1.2.1",
3
+ "version": "1.4.0",
4
4
  "description": "React Native SDK for Everframe — AI-ready in-app bug reporting, bridging the native iOS and Android reporter to your RN app. Supports phone, Apple TV, and Android TV.",
5
5
  "author": {
6
6
  "name": "Scriptx, UAB",
@@ -100,7 +100,7 @@
100
100
  "dependencies": {
101
101
  "bippy": "^0.5.39",
102
102
  "zod": "^4.3.0",
103
- "@everframe/react": "^1.2.1"
103
+ "@everframe/react": "^1.4.0"
104
104
  },
105
105
  "devDependencies": {
106
106
  "@arethetypeswrong/cli": "^0.18.0",
@@ -108,12 +108,13 @@
108
108
  "@types/react": "^19.0.0",
109
109
  "@types/react-dom": "^19.0.0",
110
110
  "jsdom": "^29.1.1",
111
- "react-dom": "^19.0.0",
112
111
  "publint": "^0.3.0",
112
+ "react-dom": "^19.0.0",
113
+ "react-native-url-polyfill": "4.0.0",
113
114
  "tsup": "^8.0.0",
114
115
  "vitest": "^4.0.0",
115
- "@everframe/sdk-core": "0.6.0",
116
- "@everframe/protocol": "0.7.0"
116
+ "@everframe/protocol": "0.9.0",
117
+ "@everframe/sdk-core": "0.6.1"
117
118
  },
118
119
  "codegenConfig": {
119
120
  "name": "EverframeSpec",