@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.
- package/EverframeRN.podspec +2 -2
- package/README.md +193 -10
- package/android/build.gradle.kts +1 -1
- package/android/src/main/java/dev/everframe/rn/EverframeModule.kt +122 -1
- package/android/src/main/java/dev/everframe/rn/RnInteractionEventObserver.kt +69 -0
- package/android/src/main/java/dev/everframe/rn/RnInteractionEventRegistration.kt +127 -0
- package/android/src/main/java/dev/everframe/rn/RnInteractionTargetAdapter.kt +33 -0
- package/android/src/main/java/dev/everframe/rn/RnInteractionTargetRegistration.kt +72 -0
- package/dist/NativeEverframe.d.ts +18 -0
- package/dist/chunk-FZHLCD3Z.js +1 -0
- package/dist/index.d.ts +37 -734
- package/dist/index.js +30 -30
- package/dist/index.web.d.ts +26 -3
- package/dist/index.web.js +1 -1
- package/dist/integrations/console.js +1 -1
- package/dist/integrations/react-navigation.js +1 -1
- package/dist/integrations/react.js +1 -1
- package/dist/launch-BvE4IjRk.d.ts +1090 -0
- package/ios/Sources/EverframeBridge.swift +29 -2
- package/ios/Sources/EverframeModule.mm +115 -6
- package/ios/Sources/RnDevtoolsOrigin.h +7 -0
- package/ios/Sources/RnDevtoolsOrigin.mm +36 -0
- package/ios/Sources/RnInteractionEventRegistration.swift +141 -0
- package/ios/Sources/RnInteractionTargetRegistration.swift +107 -0
- package/package.json +6 -5
- package/src/NativeEverframe.ts +38 -22
- package/src/codegen-types.ts +0 -1
- package/dist/chunk-MIWWEGLO.js +0 -1
- package/dist/promise-rejection-types-Bf5iLv8n.d.ts +0 -99
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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/
|
|
116
|
-
"@everframe/
|
|
116
|
+
"@everframe/protocol": "0.9.0",
|
|
117
|
+
"@everframe/sdk-core": "0.6.1"
|
|
117
118
|
},
|
|
118
119
|
"codegenConfig": {
|
|
119
120
|
"name": "EverframeSpec",
|