From fbbce3a2702fffe4783fe4657a402773f18332eb Mon Sep 17 00:00:00 2001 From: Denis Sheikherev Date: Fri, 2 Oct 2026 15:25:38 +0700 Subject: [PATCH 1/6] fix(ios): move secure rectangles from the React root's window to the screen JS measures BugseeSecure views and published rectangles with measureInWindow, in the React root's window, and iOS handed them to the SDK as they were. The SDK's contract puts secure rectangles in screen points, and the SDK that composes every window of the app on the screen (iPad Stage Manager and Split View, iPhone Duo side by side) draws them there: in a window away from the screen's origin each mask landed that far up and left of its view, which was recorded in the clear. Android already moves its rectangles by the root's display origin (ReactRootOriginTracker). The store now keeps JS's rectangles and serves them moved by where the React root's window starts in the frame the SDK records: its place on the screen where the SDK composes, its frame.origin on a Mac, where the SDK records the key window's scene alone. A fractional origin only grows a rectangle, edges saturate, and the version moves only when what is served changes. BGSRNReactRootOriginTracker keeps the root view weakly and reads its window's place; it searches for the root only on a JS publish, so the SDK's pulls never walk the windows. BGSRNSecureRectanglePulls refreshes the origin on the SDK's pull at most every 100 ms, before the snapshot when on main, as Android's SecureRectanglePulls does. Support tests 246/246: the store (origin, version, rounding, saturation, displays), the tracker (root cache, no search on pulls, the last origin kept, exceptions) and the pulls (throttle, off main, the served origin). The example builds with CocoaPods and SPM; launch, secure-component and view-tree e2e pass on iOS 27 and 26.5 simulators. On an iPad Air 5 in Stage Manager, window at {359, 64}, with the SDK built from bugsee-cocoa: the mask lies on the component in the video and in the report screenshot within 1-2 px, before and after a scroll, and is gone after unmount. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/react-native/ios/BugseeModule.mm | 72 +++++++- .../BGSRNReactRootOriginTracker.m | 56 ++++++ .../BugseeRNSupport/BGSRNReactWindow.m | 61 +++++-- .../BGSRNSecureRectanglePulls.m | 79 +++++++++ .../BugseeRNSupport/BGSRNSecureRectangles.m | 82 ++++++++- .../include/BGSRNReactRootOriginTracker.h | 52 ++++++ .../include/BGSRNReactWindow.h | 27 ++- .../include/BGSRNSecureRectanglePulls.h | 45 +++++ .../include/BGSRNSecureRectangles.h | 24 ++- .../BGSRNReactRootOriginTrackerTests.m | 164 ++++++++++++++++++ .../BGSRNReactWindowTests.m | 39 +++++ .../BGSRNSecureOriginTestSupport.h | 18 ++ .../BGSRNSecureRectanglePullsTests.m | 128 ++++++++++++++ .../BGSRNSecureRectanglesTests.m | 120 ++++++++++++- packages/react-native/src/NativeBugsee.ts | 4 +- .../react-native/src/secure/BugseeSecure.tsx | 7 +- 16 files changed, 943 insertions(+), 35 deletions(-) create mode 100644 packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactRootOriginTracker.m create mode 100644 packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectanglePulls.m create mode 100644 packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactRootOriginTracker.h create mode 100644 packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSecureRectanglePulls.h create mode 100644 packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactRootOriginTrackerTests.m create mode 100644 packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureOriginTestSupport.h create mode 100644 packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglePullsTests.m diff --git a/packages/react-native/ios/BugseeModule.mm b/packages/react-native/ios/BugseeModule.mm index d1bd3bf2..b28a9b68 100644 --- a/packages/react-native/ios/BugseeModule.mm +++ b/packages/react-native/ios/BugseeModule.mm @@ -17,6 +17,8 @@ #import #import #import +#import +#import #import #import #import @@ -38,6 +40,8 @@ #import "BGSRNWrapperChannelHolder.h" #import "BGSRNStatusMapper.h" #import "BGSRNSecureRectangles.h" +#import "BGSRNSecureRectanglePulls.h" +#import "BGSRNReactRootOriginTracker.h" #import "BGSRNEventBus.h" #import "BGSRNTokens.h" #import "BGSRNReportHandlerBridge.h" @@ -55,6 +59,9 @@ #import "BGSRNCreatedReportOps.h" #endif +/// Defined below, beside the React root lookup it closes over. +static BGSRNReactRootOriginTracker *BGSRNSecureOriginTracker(void); + /// The conformance lives here rather than in the Support package so that the /// package stays buildable and testable without the SDK's headers. BGSRNWrapper /// already declares every property the protocol requires; this states that it @@ -87,15 +94,19 @@ - (void)onLifecycleEvent:(NSString *)eventType data:(id)data { } /// The packed buffer the SDK expects: `[version, count, l,t,r,b, ...]` as -/// little-endian int32. +/// little-endian int32, every rectangle moved from the React root's window to +/// the screen. /// /// Read from the process-wide store rather than from this instance. The SDK /// pulls 2-3 times a second on the MAIN thread, and the wrapper it pulls /// through is replaced when `setWrapperInfo` runs — regions the app marked /// secret must survive that swap. See `BGSRNSecureRectangles` for the version -/// contract, which is what makes the SDK notice a change at all. +/// contract, which is what makes the SDK notice a change at all. The pull +/// also re-reads the window's place on the screen +/// (`BGSRNSecureRectanglePulls`): a window can move with nothing published. - (NSData *)secureRectanglesForDisplay:(NSInteger)display { - return [BGSRNSecureRectangles.shared snapshotForDisplay:display]; + (void)BGSRNSecureOriginTracker(); // installs the pulls' refresher on first use + return [BGSRNSecureRectanglePulls.shared pullForDisplay:display]; } /// Through the data request bridge, for the same reason lifecycle events go @@ -205,10 +216,7 @@ static void BGSRNSetWrapper(id _Nullable wrapper, BOOL onlyIfAbse /// is the new architecture's root (the template's `RCTRootView` is its /// `RCTSurfaceHostingProxyRootView` subclass); the legacy `RCTRootView` class /// is matched too for interop hosts. -static NSValue *_Nullable BGSRNReactOrigin(void) { - if (!NSThread.isMainThread) { - return nil; - } +static BOOL BGSRNIsReactRoot(UIView *view) { static Class surfaceHostingView; static Class legacyRootView; static dispatch_once_t once; @@ -216,13 +224,52 @@ static void BGSRNSetWrapper(id _Nullable wrapper, BOOL onlyIfAbse surfaceHostingView = NSClassFromString(@"RCTSurfaceHostingView"); legacyRootView = NSClassFromString(@"RCTRootView"); }); + return (surfaceHostingView != Nil && [view isKindOfClass:surfaceHostingView]) || + (legacyRootView != Nil && [view isKindOfClass:legacyRootView]); +} + +static NSValue *_Nullable BGSRNReactOrigin(void) { + if (!NSThread.isMainThread) { + return nil; + } UIWindow *keyWindow = BGSRNSdkKeyWindow(); return BGSRNReactRootOrigin(keyWindow, BGSRNSdkWalkedWindows(keyWindow), ^BOOL(UIView *view) { - return (surfaceHostingView != Nil && [view isKindOfClass:surfaceHostingView]) || - (legacyRootView != Nil && [view isKindOfClass:legacyRootView]); + return BGSRNIsReactRoot(view); }); } +/// Keeps the secure rectangles on the window JS measures them in: the iOS +/// peer of Android's `ReactRootOriginTracker` (see +/// `BGSRNReactRootOriginTracker`). Process-wide, like the store and the pulls +/// it feeds: the window lookup reads only UIKit, nothing of one module. +/// Created by the first pull or publish, which also installs it as the pulls' +/// refresher. +static BGSRNReactRootOriginTracker *BGSRNSecureOriginTracker(void) { + static BGSRNReactRootOriginTracker *tracker = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + tracker = [[BGSRNReactRootOriginTracker alloc] + initWithStore:BGSRNSecureRectangles.shared + findRoot:^UIView *_Nullable { + UIWindow *keyWindow = BGSRNSdkKeyWindow(); + return BGSRNReactRootView(keyWindow, + BGSRNSdkWalkedWindows(keyWindow), + ^BOOL(UIView *view) { + return BGSRNIsReactRoot(view); + }, + BGSRNReactRootSearchBudget); + } + readOrigin:^NSValue *_Nullable(UIWindow *window) { + return BGSRNWindowRecordedOrigin(window); + }]; + BGSRNReactRootOriginTracker *installed = tracker; + BGSRNSecureRectanglePulls.shared.refresher = ^{ + [installed refresh]; + }; + }); + return tracker; +} + static NSString *const kHandleDeadCode = @"E_REPORT_HANDLE_DEAD"; static NSString *const kCreateBusyCode = @"E_REPORT_CREATE_BUSY"; @@ -428,6 +475,13 @@ - (void)setSecureRectangles:(double)display count:count forDisplay:(NSInteger)display]; free(flat); + // JS measured in the React root's window; where that window sits on the + // screen is read on main, as Android re-reads its root's display origin on + // every publish. + BGSRNReactRootOriginTracker *tracker = BGSRNSecureOriginTracker(); + BGSRNRunOnMain(^{ + [tracker refreshFindingTheRoot]; + }); } #pragma mark - Blackout and view-hierarchy capture (design doc §4.1) diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactRootOriginTracker.m b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactRootOriginTracker.m new file mode 100644 index 00000000..2a95de44 --- /dev/null +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactRootOriginTracker.m @@ -0,0 +1,56 @@ +#import "BGSRNReactRootOriginTracker.h" + +#import "BGSRNSecureRectangles.h" + +@implementation BGSRNReactRootOriginTracker { + BGSRNSecureRectangles *_store; + UIView *_Nullable (^_findRoot)(void); + NSValue *_Nullable (^_readOrigin)(UIWindow *window); + /// The React root found last. Weak: a reload replaces it, and the tracker + /// must not keep the old one alive. + __weak UIView *_root; +} + +- (instancetype)initWithStore:(BGSRNSecureRectangles *)store + findRoot:(UIView *_Nullable (^)(void))findRoot + readOrigin:(NSValue *_Nullable (^)(UIWindow *window))readOrigin { + self = [super init]; + if (self) { + _store = store; + _findRoot = [findRoot copy]; + _readOrigin = [readOrigin copy]; + } + return self; +} + +- (void)refreshFindingTheRoot { + [self refreshFinding:YES]; +} + +- (void)refresh { + [self refreshFinding:NO]; +} + +- (void)refreshFinding:(BOOL)find { + @try { + UIView *root = _root; + if (root.window == nil && find) { + root = _findRoot(); + _root = root; + } + UIWindow *window = [root isKindOfClass:UIWindow.class] ? (UIWindow *)root : root.window; + if (window == nil) { + return; + } + NSValue *origin = _readOrigin(window); + if (origin == nil) { + return; + } + [_store setOrigin:origin.CGPointValue forDisplay:0]; + } @catch (NSException *exception) { + NSLog(@"[Bugsee] secure rectangles: could not read the React root's place on the screen: %@", + exception); + } +} + +@end diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactWindow.m b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactWindow.m index db4b6d0a..caaff524 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactWindow.m +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactWindow.m @@ -61,36 +61,60 @@ return windows ?: @[]; } +/// Whether the SDK composes the app's windows on the screen: iOS, and not an +/// iPhone or iPad app running on a Mac (`+[BGSTrackerApplication +/// capturesAppScreen]`). +static BOOL BGSRNSdkComposesScreen(void) { +#if TARGET_OS_MACCATALYST + return NO; +#else + return !NSProcessInfo.processInfo.isiOSAppOnMac; +#endif +} + /// Breadth-first: a React root is near the top of its window, and a deep /// native subtree beside it should not be searched first. -static BOOL HostsReactRoot(UIWindow *window, BOOL (^isReactRoot)(UIView *), NSUInteger budget) { +static UIView *ReactRootInWindow(UIWindow *window, BOOL (^isReactRoot)(UIView *), NSUInteger budget) { NSMutableArray *queue = [NSMutableArray arrayWithObject:window]; NSUInteger head = 0; while (head < queue.count && head < budget) { UIView *view = queue[head++]; if (isReactRoot(view)) { - return YES; + return view; } [queue addObjectsFromArray:view.subviews]; } - return NO; + return nil; } -UIWindow *BGSRNWindowHostingReactRoot(UIWindow *keyWindow, - NSArray *windows, - BOOL (^isReactRoot)(UIView *), - NSUInteger budget) { - if (keyWindow != nil && HostsReactRoot(keyWindow, isReactRoot, budget)) { - return keyWindow; +UIView *BGSRNReactRootView(UIWindow *keyWindow, + NSArray *windows, + BOOL (^isReactRoot)(UIView *), + NSUInteger budget) { + UIView *root = keyWindow != nil ? ReactRootInWindow(keyWindow, isReactRoot, budget) : nil; + if (root != nil) { + return root; } for (UIWindow *window in windows) { - if (window != keyWindow && HostsReactRoot(window, isReactRoot, budget)) { - return window; + if (window != keyWindow) { + root = ReactRootInWindow(window, isReactRoot, budget); + if (root != nil) { + return root; + } } } return nil; } +UIWindow *BGSRNWindowHostingReactRoot(UIWindow *keyWindow, + NSArray *windows, + BOOL (^isReactRoot)(UIView *), + NSUInteger budget) { + UIView *root = BGSRNReactRootView(keyWindow, windows, isReactRoot, budget); + // A root found in a window's tree is in that window, unless it is the window itself. + return [root isKindOfClass:UIWindow.class] ? (UIWindow *)root : root.window; +} + NSValue *BGSRNReactRootOrigin(UIWindow *keyWindow, NSArray *windows, BOOL (^isReactRoot)(UIView *)) { UIWindow *window = BGSRNWindowHostingReactRoot(keyWindow, windows, isReactRoot, BGSRNReactRootSearchBudget); if (window == nil) { @@ -100,3 +124,18 @@ static BOOL HostsReactRoot(UIWindow *window, BOOL (^isReactRoot)(UIView *), NSUI // space: the SDK adds exactly this (BGSCaptureViewHierarchyEngine.m:335-336). return [NSValue valueWithCGPoint:window.frame.origin]; } + +NSValue *BGSRNWindowRecordedOrigin(UIWindow *window) { + UIScreen *screen = window.windowScene.screen; + if (screen == nil) { + return nil; + } + if (!BGSRNSdkComposesScreen()) { + return [NSValue valueWithCGPoint:window.frame.origin]; + } + id fixedSpace = screen.fixedCoordinateSpace; + const CGRect inFixedSpace = [window convertRect:window.bounds toCoordinateSpace:fixedSpace]; + const CGRect onScreen = [fixedSpace convertRect:inFixedSpace + toCoordinateSpace:screen.coordinateSpace]; + return [NSValue valueWithCGPoint:onScreen.origin]; +} diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectanglePulls.m b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectanglePulls.m new file mode 100644 index 00000000..71f77bc3 --- /dev/null +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectanglePulls.m @@ -0,0 +1,79 @@ +#import "BGSRNSecureRectanglePulls.h" + +#import + +#import "BGSRNSecureRectangles.h" + +const NSTimeInterval BGSRNOriginRefreshMinInterval = 0.1; + +/// Runs `refresher` without letting an exception reach the SDK's pull. +static void BGSRNRunRefresher(dispatch_block_t refresher) { + @try { + refresher(); + } @catch (NSException *exception) { + NSLog(@"[Bugsee] secure rectangles: the origin refresh threw: %@", exception); + } +} + +@implementation BGSRNSecureRectanglePulls { + BGSRNSecureRectangles *_store; + NSTimeInterval (^_clock)(void); + os_unfair_lock _lock; + /// Guarded by `_lock`. + BOOL _hasRefreshed; + NSTimeInterval _lastRefresh; +} + ++ (BGSRNSecureRectanglePulls *)shared { + static BGSRNSecureRectanglePulls *shared = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + shared = [[BGSRNSecureRectanglePulls alloc] + initWithStore:BGSRNSecureRectangles.shared + clock:^NSTimeInterval { + return NSProcessInfo.processInfo.systemUptime; + }]; + }); + return shared; +} + +- (instancetype)initWithStore:(BGSRNSecureRectangles *)store + clock:(NSTimeInterval (^)(void))clock { + self = [super init]; + if (self) { + _store = store; + _clock = [clock copy]; + _lock = OS_UNFAIR_LOCK_INIT; + } + return self; +} + +- (NSData *)pullForDisplay:(NSInteger)display { + dispatch_block_t refresher = self.refresher; + if (refresher != nil && [self claimRefresh]) { + if (NSThread.isMainThread) { + BGSRNRunRefresher(refresher); + } else { + dispatch_async(dispatch_get_main_queue(), ^{ + BGSRNRunRefresher(refresher); + }); + } + } + return [_store snapshotForDisplay:display]; +} + +/// YES when a refresh is due, and records it as made: the first pull, then +/// one per `BGSRNOriginRefreshMinInterval`. +- (BOOL)claimRefresh { + const NSTimeInterval now = _clock(); + os_unfair_lock_lock(&_lock); + const BOOL due = !_hasRefreshed || now - _lastRefresh >= BGSRNOriginRefreshMinInterval; + if (due) { + _hasRefreshed = YES; + _lastRefresh = now; + } + os_unfair_lock_unlock(&_lock); + return due; +} + +@end diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m index 56381d7c..aec7a739 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m @@ -19,8 +19,46 @@ static int32_t BGSRNNextVersion(int32_t current) { return next == kInitialVersion ? kInitialVersion + 1 : next; } +/// One coordinate moved by the origin, rounded outward: down for a left or top +/// edge, up for a right or bottom one, so the region can only grow. Saturates +/// at the int32 range. +static int32_t BGSRNMovedCoordinate(int32_t value, NSUInteger index, CGPoint origin) { + const BOOL isX = index % 2 == 0; + const BOOL isLeadingEdge = index % kCoordinatesPerRectangle < 2; + const double moved = (double)value + (isX ? origin.x : origin.y); + const double rounded = isLeadingEdge ? floor(moved) : ceil(moved); + if (rounded >= (double)INT32_MAX) { + return INT32_MAX; + } + if (rounded <= (double)INT32_MIN) { + return INT32_MIN; + } + return (int32_t)rounded; +} + +/// `raw` (int32 coordinates) with every coordinate moved by `origin`. +static NSData *BGSRNMovedCoordinates(NSData *raw, CGPoint origin) { + if (origin.x == 0 && origin.y == 0) { + return raw; + } + const NSUInteger count = raw.length / sizeof(int32_t); + NSMutableData *moved = [NSMutableData dataWithLength:raw.length]; + const int32_t *in = (const int32_t *)raw.bytes; + int32_t *out = (int32_t *)moved.mutableBytes; + for (NSUInteger i = 0; i < count; i++) { + out[i] = BGSRNMovedCoordinate(in[i], i, origin); + } + return [moved copy]; +} + @implementation BGSRNSecureRectangles { - /// display -> the coordinates last published for it, as NSData of int32. + /// display -> the coordinates JS last published for it, in its window's + /// points, as NSData of int32. + NSMutableDictionary *_rawByDisplay; + /// display -> the window's place on that display's screen, a CGPoint. + NSMutableDictionary *_originByDisplay; + /// display -> the coordinates served to the SDK: the raw ones moved by the + /// origin. Absent until something is published for the display. NSMutableDictionary *_coordinatesByDisplay; /// display -> its current version. NSMutableDictionary *_versionsByDisplay; @@ -42,6 +80,8 @@ + (BGSRNSecureRectangles *)shared { - (instancetype)init { self = [super init]; if (self) { + _rawByDisplay = [NSMutableDictionary dictionary]; + _originByDisplay = [NSMutableDictionary dictionary]; _coordinatesByDisplay = [NSMutableDictionary dictionary]; _versionsByDisplay = [NSMutableDictionary dictionary]; _lock = [[NSLock alloc] init]; @@ -67,16 +107,46 @@ - (BOOL)setCoordinates:(const int32_t *)coordinates NSNumber *key = @(display); [_lock lock]; + _rawByDisplay[key] = published; + [self serveLocked:key]; + [_lock unlock]; + + return YES; +} + +- (void)setOrigin:(CGPoint)origin forDisplay:(NSInteger)display { + NSNumber *key = @(display); + [_lock lock]; + // The usual case: re-read on every pull, the window has not moved. + CGPoint previous = CGPointZero; + NSValue *recorded = _originByDisplay[key]; + [recorded getValue:&previous size:sizeof(previous)]; + if (recorded != nil && CGPointEqualToPoint(previous, origin)) { + [_lock unlock]; + return; + } + _originByDisplay[key] = [NSValue valueWithBytes:&origin objCType:@encode(CGPoint)]; + // Nothing published yet: the display keeps reporting the empty set at its + // initial version, and the origin applies to whatever comes. + if (_rawByDisplay[key] != nil) { + [self serveLocked:key]; + } + [_lock unlock]; +} + +/// Moves the display's raw coordinates by its origin and serves them, moving +/// the version only when what is served changes. Called with `_lock` held. +- (void)serveLocked:(NSNumber *)key { + CGPoint origin = CGPointZero; + [_originByDisplay[key] getValue:&origin size:sizeof(origin)]; + NSData *served = BGSRNMovedCoordinates(_rawByDisplay[key], origin); NSData *previous = _coordinatesByDisplay[key]; - if (previous == nil || ![previous isEqualToData:published]) { + if (previous == nil || ![previous isEqualToData:served]) { const int32_t currentVersion = previous == nil ? kInitialVersion : _versionsByDisplay[key].intValue; - _coordinatesByDisplay[key] = published; + _coordinatesByDisplay[key] = served; _versionsByDisplay[key] = @(BGSRNNextVersion(currentVersion)); } - [_lock unlock]; - - return YES; } - (NSData *)snapshotForDisplay:(NSInteger)display { diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactRootOriginTracker.h b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactRootOriginTracker.h new file mode 100644 index 00000000..b6c7d091 --- /dev/null +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactRootOriginTracker.h @@ -0,0 +1,52 @@ +#import + +@class BGSRNSecureRectangles; + +NS_ASSUME_NONNULL_BEGIN + +/// Keeps the secure-rectangle store's origin on the window JS measures in: the +/// iOS peer of Android's `ReactRootOriginTracker`. +/// +/// JS measures secure views with `measureInWindow`, in the window's points; +/// the SDK wants them where it draws them, and on iOS it records every window +/// of the app at its place on the screen. The tracker reads where the window +/// hosting the React root starts there and records it as display 0's origin -- +/// display 0 is the screen the app is on, the only one the SDK pulls on iOS. +/// +/// It keeps the React root view it found, weakly, and follows `root.window`, +/// which costs nothing and still answers when the root is moved into another +/// window. Searching the windows for a root happens only on a JS publish, and +/// only when the root found last has left its window: the SDK's pulls run ten +/// times a second on the main thread, and in an app showing no React root at +/// all (the native screens of a brownfield app) a search would walk every +/// window to its budget each time. +/// +/// Main thread only: it reads UIKit. +@interface BGSRNReactRootOriginTracker : NSObject + +/// @param findRoot the React root view, or nil. +/// @param readOrigin where a window starts in the frame the SDK records, a +/// boxed `CGPoint`, or nil when it is on no screen. +- (instancetype)initWithStore:(BGSRNSecureRectangles *)store + findRoot:(UIView *_Nullable (^)(void))findRoot + readOrigin:(NSValue *_Nullable (^)(UIWindow *window))readOrigin + NS_DESIGNATED_INITIALIZER; +- (instancetype)init NS_UNAVAILABLE; + +/// For a JS publish: searches for the React root when the one found last has +/// left its window -- a publish means a root measured something -- then +/// records where its window starts. +- (void)refreshFindingTheRoot; + +/// For the SDK's pulls: records where the window of the root found last starts +/// now, without searching. A window can move (Stage Manager, Split View) with +/// nothing published. +/// +/// Both keep the last origin when the root, its window or its place cannot be +/// read: a guessed origin would move every region off the view it covers. Both +/// never throw -- they run inside the SDK's pull. +- (void)refresh; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactWindow.h b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactWindow.h index 41ffda81..e60fe373 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactWindow.h +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactWindow.h @@ -35,9 +35,15 @@ FOUNDATION_EXPORT UIWindow *_Nullable BGSRNSdkKeyWindow(void); /// the React Native template's case: it declares no manifest. FOUNDATION_EXPORT NSArray *BGSRNSdkWalkedWindows(UIWindow *_Nullable keyWindow); -/// The first window whose view tree, searched breadth-first over at most -/// `budget` views per window, has a view `isReactRoot` accepts. `keyWindow` is -/// tried first, then `windows` in order. nil if none does. +/// The first view `isReactRoot` accepts, searching each window's tree +/// breadth-first over at most `budget` views. `keyWindow` is tried first, then +/// `windows` in order. nil if no window has one. +FOUNDATION_EXPORT UIView *_Nullable BGSRNReactRootView(UIWindow *_Nullable keyWindow, + NSArray *windows, + BOOL (^isReactRoot)(UIView *view), + NSUInteger budget); + +/// The window hosting that view (`BGSRNReactRootView`), or nil. FOUNDATION_EXPORT UIWindow *_Nullable BGSRNWindowHostingReactRoot(UIWindow *_Nullable keyWindow, NSArray *windows, BOOL (^isReactRoot)(UIView *view), @@ -50,4 +56,19 @@ FOUNDATION_EXPORT NSValue *_Nullable BGSRNReactRootOrigin(UIWindow *_Nullable ke NSArray *windows, BOOL (^isReactRoot)(UIView *view)); +/// Where `window`'s own coordinate space starts in the frame the SDK records, +/// in points, boxed as a `CGPoint`: what to add to a `measureInWindow` +/// rectangle to put it where the SDK draws it, as its secure-rectangle +/// contract wants. nil when the window is on no screen. +/// +/// The SDK records every window of the app at its place on the screen on iOS, +/// so this is the window's place on its screen (interface orientation), +/// through the screen's fixed (portrait) space as the SDK places windows +/// (`+[BGSTrackerApplication screenRectForRect:inView:]`): converted straight +/// to `screen.coordinateSpace`, a scene that shares its screen (iPad tiling, +/// iPhone Duo side by side) reads {0, 0}. An iPhone or iPad app running on a +/// Mac, and Mac Catalyst, record the key window's scene alone, where the SDK +/// adds `frame.origin` (`+[BGSTrackerApplication captureRectForRect:inView:]`). +FOUNDATION_EXPORT NSValue *_Nullable BGSRNWindowRecordedOrigin(UIWindow *window); + NS_ASSUME_NONNULL_END diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSecureRectanglePulls.h b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSecureRectanglePulls.h new file mode 100644 index 00000000..3d990f3e --- /dev/null +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSecureRectanglePulls.h @@ -0,0 +1,45 @@ +#import + +@class BGSRNSecureRectangles; + +NS_ASSUME_NONNULL_BEGIN + +/// The fastest a pull asks for an origin refresh, in seconds. The same 100 ms +/// as Android's `SecureRectanglePulls`, and as JS's remeasure loop. +FOUNDATION_EXPORT const NSTimeInterval BGSRNOriginRefreshMinInterval; + +/// Serves the SDK's secure-rectangle pull and uses it to keep the origin +/// fresh: the iOS peer of Android's `SecureRectanglePulls`. +/// +/// The served rectangles are JS's measurements moved by the React root's +/// place on the screen (`BGSRNReactRootOriginTracker`). JS publishes only when +/// a measurement changes, and a window can move without one (Stage Manager, +/// Split View). So each pull also asks for a refresh, at most every +/// `BGSRNOriginRefreshMinInterval`. On the main thread, where the SDK pulls, +/// the refresh runs before the snapshot is taken and the pull already serves +/// the window's current place; off main it is posted to main and a later pull +/// serves it. +/// +/// Process-wide, like the store: the wrapper the SDK pulls through is +/// replaced mid-session, while the refresher belongs to whichever module is +/// live. +@interface BGSRNSecureRectanglePulls : NSObject + +@property (class, readonly) BGSRNSecureRectanglePulls *shared; + +/// @param clock seconds from a monotonic source. +- (instancetype)initWithStore:(BGSRNSecureRectangles *)store + clock:(NSTimeInterval (^)(void))clock NS_DESIGNATED_INITIALIZER; +- (instancetype)init NS_UNAVAILABLE; + +/// What a pull runs to refresh the origin, or nil for none. A refresher that +/// throws is logged, and the snapshot is served regardless. +@property (atomic, copy, nullable) dispatch_block_t refresher; + +/// The SDK's pull for `display`: refreshes the origin when due, then returns +/// the store's snapshot. +- (NSData *)pullForDisplay:(NSInteger)display; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSecureRectangles.h b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSecureRectangles.h index e3e9e751..5076f131 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSecureRectangles.h +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSecureRectangles.h @@ -1,3 +1,4 @@ +#import #import NS_ASSUME_NONNULL_BEGIN @@ -47,8 +48,27 @@ NS_ASSUME_NONNULL_BEGIN count:(NSUInteger)count forDisplay:(NSInteger)display; -/// The buffer for `display`. A display nothing has secured reports an empty -/// set rather than nil, so the SDK always has a version to compare against. +/// Records where the window JS measures in sits on `display`'s screen, in +/// points, and serves every rectangle of that display moved by it. +/// +/// JS measures with `measureInWindow`, in the window's points. The SDK wants +/// the screen's points (`BGSContracts.h`) and composes every window of the app +/// on the screen, so in a window away from the screen's origin (iPad Stage +/// Manager, the right-hand side of Split View, iPhone Duo side by side) an +/// unmoved rectangle lands that far up and left of the view it covers, and +/// the view is recorded in the clear. Android moves its rectangles by the +/// React root's display origin the same way. +/// +/// The origin can be fractional. Left and top edges are moved and rounded +/// down, right and bottom ones up: a rectangle may grow by under a point, +/// never shrink. Edges saturate at the int32 range rather than wrap. The +/// version moves only when the served rectangles change, so re-recording the +/// same origin costs the SDK nothing. +- (void)setOrigin:(CGPoint)origin forDisplay:(NSInteger)display; + +/// The buffer for `display`, every rectangle moved by its origin. A display +/// nothing has secured reports an empty set rather than nil, so the SDK always +/// has a version to compare against. - (NSData *)snapshotForDisplay:(NSInteger)display; @end diff --git a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactRootOriginTrackerTests.m b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactRootOriginTrackerTests.m new file mode 100644 index 00000000..a12dc9fa --- /dev/null +++ b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactRootOriginTrackerTests.m @@ -0,0 +1,164 @@ +@import XCTest; +@import UIKit; +@import BugseeRNSupport; + +#import "BGSRNSecureOriginTestSupport.h" + +/// The tracker records where the React root's window sits on the screen, so +/// the SDK gets JS's window-point rectangles where it draws them. What it must +/// never do is guess -- a made-up origin moves every region off its view -- +/// or walk every window on each of the SDK's pulls. +@interface BGSRNReactRootOriginTrackerTests : XCTestCase +@end + +@implementation BGSRNReactRootOriginTrackerTests { + BGSRNSecureRectangles *_store; + UIWindow *_window; + UIView *_root; + NSUInteger _searches; + NSValue *_origin; +} + +- (void)setUp { + [super setUp]; + _store = [[BGSRNSecureRectangles alloc] init]; + _window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; + _root = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 10, 10)]; + [_window addSubview:_root]; + _searches = 0; + _origin = [NSValue valueWithCGPoint:CGPointMake(100, 50)]; + const int32_t rects[] = {10, 20, 30, 40}; + [_store setCoordinates:rects count:4 forDisplay:0]; +} + +/// Finds `_root` (or nothing once it is nil) and reads `_origin` for the +/// window it is in, counting the searches. +- (BGSRNReactRootOriginTracker *)tracker { + __weak __typeof(self) weakSelf = self; + return [[BGSRNReactRootOriginTracker alloc] initWithStore:_store + findRoot:^UIView * { + __typeof(self) strongSelf = weakSelf; + strongSelf->_searches += 1; + return strongSelf->_root; + } + readOrigin:^NSValue *(UIWindow *window) { + __typeof(self) strongSelf = weakSelf; + return window == strongSelf->_root.window ? strongSelf->_origin : nil; + }]; +} + +- (NSArray *)served { + return BGSRNServedCoordinates([_store snapshotForDisplay:0]); +} + +- (void)testRecordsTheWindowsPlaceForDisplayZero { + [[self tracker] refreshFindingTheRoot]; + + XCTAssertEqualObjects([self served], (@[@110, @70, @130, @90])); +} + +/// The SDK pulls ten times a second on the main thread. With no React root on +/// screen (a brownfield app's native screens) a search there would walk every +/// window to its budget each time. +- (void)testAPullNeverSearches { + BGSRNReactRootOriginTracker *tracker = [self tracker]; + + [tracker refresh]; + [tracker refresh]; + + XCTAssertEqual(_searches, 0u); + XCTAssertEqualObjects([self served], (@[@10, @20, @30, @40])); +} + +/// A window dragged across the screen: the pull re-reads its place through +/// the root it already has. +- (void)testAPullFollowsTheWindowOfTheRootFoundLast { + BGSRNReactRootOriginTracker *tracker = [self tracker]; + [tracker refreshFindingTheRoot]; + + _origin = [NSValue valueWithCGPoint:CGPointMake(200, 80)]; + [tracker refresh]; + + XCTAssertEqualObjects([self served], (@[@210, @100, @230, @120])); + XCTAssertEqual(_searches, 1u); +} + +- (void)testAPublishDoesNotSearchWhileTheRootIsInAWindow { + BGSRNReactRootOriginTracker *tracker = [self tracker]; + [tracker refreshFindingTheRoot]; + + [tracker refreshFindingTheRoot]; + + XCTAssertEqual(_searches, 1u); +} + +/// A reload replaces the root; the next publish finds the new one. +- (void)testAPublishSearchesAgainOnceTheRootHasLeftItsWindow { + BGSRNReactRootOriginTracker *tracker = [self tracker]; + [tracker refreshFindingTheRoot]; + [_root removeFromSuperview]; + _root = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 10, 10)]; + [_window addSubview:_root]; + + [tracker refreshFindingTheRoot]; + + XCTAssertEqual(_searches, 2u); + XCTAssertEqualObjects([self served], (@[@110, @70, @130, @90])); +} + +/// A window off its screen for a moment (a scene disconnecting) must not +/// throw the regions back to the window's own corner. +- (void)testKeepsTheLastOriginWhenThePlaceCannotBeRead { + BGSRNReactRootOriginTracker *tracker = [self tracker]; + [tracker refreshFindingTheRoot]; + + _origin = nil; + [tracker refresh]; + + XCTAssertEqualObjects([self served], (@[@110, @70, @130, @90])); +} + +- (void)testKeepsTheLastOriginWhenNoRootIsFound { + BGSRNReactRootOriginTracker *tracker = [self tracker]; + [tracker refreshFindingTheRoot]; + [_root removeFromSuperview]; + _root = nil; + + [tracker refreshFindingTheRoot]; + + XCTAssertEqualObjects([self served], (@[@110, @70, @130, @90])); +} + +/// They run inside the SDK's pull, where an exception would take the host +/// app down over a redaction offset. +- (void)testSwallowsAnExceptionFromTheSearch { + BGSRNReactRootOriginTracker *tracker = + [[BGSRNReactRootOriginTracker alloc] initWithStore:_store + findRoot:^UIView * { + [NSException raise:@"Test" format:@"search"]; + return nil; + } + readOrigin:^NSValue *(UIWindow *window) { + return nil; + }]; + + XCTAssertNoThrow([tracker refreshFindingTheRoot]); +} + +- (void)testSwallowsAnExceptionFromReadingThePlace { + UIView *root = _root; + BGSRNReactRootOriginTracker *tracker = + [[BGSRNReactRootOriginTracker alloc] initWithStore:_store + findRoot:^UIView * { + return root; + } + readOrigin:^NSValue *(UIWindow *window) { + [NSException raise:@"Test" format:@"read"]; + return nil; + }]; + + XCTAssertNoThrow([tracker refreshFindingTheRoot]); + XCTAssertEqualObjects([self served], (@[@10, @20, @30, @40])); +} + +@end diff --git a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactWindowTests.m b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactWindowTests.m index cabd2e4f..95e7ecd3 100644 --- a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactWindowTests.m +++ b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactWindowTests.m @@ -121,4 +121,43 @@ - (void)testTheSdkWalkedWindowsAreEmptyWithoutAKeyWindow { XCTAssertEqualObjects(BGSRNSdkWalkedWindows(nil), @[]); } +/// The tracker keeps the root view itself, so the search hands it back. +- (void)testTheSearchReturnsTheRootView { + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 10, 10)]; + UIView *container = [UIView new]; + BGSRNFakeReactRoot *root = [BGSRNFakeReactRoot new]; + [container addSubview:root]; + [window addSubview:container]; + + XCTAssertEqual(BGSRNReactRootView(window, @[], _isReactRoot, BGSRNReactRootSearchBudget), root); + XCTAssertNil(BGSRNReactRootView(nil, @[], _isReactRoot, BGSRNReactRootSearchBudget)); +} + +/// A window that is not on a screen has no place on one; the tracker then +/// keeps the last origin rather than serve the rectangles at {0, 0}. +- (void)testAWindowOnNoScreenHasNoScreenOrigin { + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(37, 53, 100, 100)]; + window.windowScene = nil; + + XCTAssertNil(BGSRNWindowRecordedOrigin(window)); +} + +/// The test runner's scene fills its iPhone screen, so a window placed at +/// {37, 53} in it starts at {37, 53} on the screen. A window side by side or +/// in Stage Manager adds its scene's place, which only a device shows. +- (void)testAWindowStartsOnTheScreenWhereItSitsInAFullScreenScene { + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(37, 53, 100, 100)]; + UIWindowScene *scene = window.windowScene; + if (scene == nil + || !CGRectEqualToRect(scene.coordinateSpace.bounds, scene.screen.bounds)) { + XCTSkip(@"The test runner has no scene filling its screen"); + } + + NSValue *origin = BGSRNWindowRecordedOrigin(window); + + XCTAssertNotNil(origin); + XCTAssertEqualWithAccuracy(origin.CGPointValue.x, 37, 0.5); + XCTAssertEqualWithAccuracy(origin.CGPointValue.y, 53, 0.5); +} + @end diff --git a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureOriginTestSupport.h b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureOriginTestSupport.h new file mode 100644 index 00000000..91ad498c --- /dev/null +++ b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureOriginTestSupport.h @@ -0,0 +1,18 @@ +@import Foundation; + +NS_ASSUME_NONNULL_BEGIN + +/// The rectangles of a packed secure-rectangle buffer, `[version, count, l, t, +/// r, b, ...]`, read as the SDK reads them: little-endian, signed. +static inline NSArray *BGSRNServedCoordinates(NSData *packed) { + NSMutableArray *out = [NSMutableArray array]; + const NSUInteger count = packed.length / sizeof(int32_t); + for (NSUInteger i = 2; i < count; i++) { + int32_t value = 0; + [packed getBytes:&value range:NSMakeRange(i * sizeof(int32_t), sizeof(int32_t))]; + [out addObject:@((int32_t)CFSwapInt32LittleToHost((uint32_t)value))]; + } + return out; +} + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglePullsTests.m b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglePullsTests.m new file mode 100644 index 00000000..39a74964 --- /dev/null +++ b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglePullsTests.m @@ -0,0 +1,128 @@ +@import XCTest; +@import BugseeRNSupport; + +#import "BGSRNSecureOriginTestSupport.h" + +/// The SDK pulls 2-3 times a second, more while capturing. Each pull keeps the +/// window's place fresh -- a window moves with nothing published -- but no +/// more often than every 100 ms, and it always gets its rectangles. +@interface BGSRNSecureRectanglePullsTests : XCTestCase +@end + +@implementation BGSRNSecureRectanglePullsTests { + BGSRNSecureRectangles *_store; + BGSRNSecureRectanglePulls *_pulls; + NSTimeInterval _now; + NSUInteger _refreshes; +} + +- (void)setUp { + [super setUp]; + _store = [[BGSRNSecureRectangles alloc] init]; + _now = 1000; + _refreshes = 0; + __weak __typeof(self) weakSelf = self; + _pulls = [[BGSRNSecureRectanglePulls alloc] initWithStore:_store + clock:^NSTimeInterval { + __typeof(self) strongSelf = weakSelf; + return strongSelf ? strongSelf->_now : 0; + }]; + _pulls.refresher = ^{ + __typeof(self) strongSelf = weakSelf; + if (strongSelf) { + strongSelf->_refreshes += 1; + } + }; + const int32_t rects[] = {10, 20, 30, 40}; + [_store setCoordinates:rects count:4 forDisplay:0]; +} + +- (void)testTheFirstPullRefreshes { + [_pulls pullForDisplay:0]; + + XCTAssertEqual(_refreshes, 1u); +} + +- (void)testAPullInsideTheIntervalDoesNotRefreshAgain { + [_pulls pullForDisplay:0]; + _now += BGSRNOriginRefreshMinInterval / 2; + + [_pulls pullForDisplay:0]; + + XCTAssertEqual(_refreshes, 1u); +} + +- (void)testAPullOnceTheIntervalHasPassedRefreshesAgain { + [_pulls pullForDisplay:0]; + _now += BGSRNOriginRefreshMinInterval; + + [_pulls pullForDisplay:0]; + + XCTAssertEqual(_refreshes, 2u); +} + +/// On the main thread the refresh lands before the snapshot is taken, so a +/// window that just moved is already served at its new place. +- (void)testServesTheOriginTheRefreshRecorded { + BGSRNSecureRectangles *store = _store; + _pulls.refresher = ^{ + [store setOrigin:CGPointMake(100, 50) forDisplay:0]; + }; + + NSData *packed = [_pulls pullForDisplay:0]; + + XCTAssertEqualObjects(BGSRNServedCoordinates(packed), (@[@110, @70, @130, @90])); +} + +- (void)testServesTheRectanglesWithNoRefresher { + _pulls.refresher = nil; + + XCTAssertEqualObjects(BGSRNServedCoordinates([_pulls pullForDisplay:0]), + (@[@10, @20, @30, @40])); +} + +/// The pull is the SDK's, on its frame path: a refresher that throws must not +/// cost it the rectangles. +- (void)testServesTheRectanglesWhenTheRefresherThrows { + _pulls.refresher = ^{ + [NSException raise:@"Test" format:@"refresh"]; + }; + + NSData *packed = nil; + XCTAssertNoThrow(packed = [_pulls pullForDisplay:0]); + XCTAssertEqualObjects(BGSRNServedCoordinates(packed), (@[@10, @20, @30, @40])); +} + +/// Every pull the SDK makes today is on the main thread; one that is not must +/// not read UIKit there. The refresh is posted to main, and a later pull +/// serves it. +- (void)testAPullOffTheMainThreadRefreshesOnMainForALaterPull { + BGSRNSecureRectangles *store = _store; + XCTestExpectation *refreshed = [self expectationWithDescription:@"refreshed"]; + XCTestExpectation *pulled = [self expectationWithDescription:@"pulled off main"]; + __block BOOL refreshedOnMain = NO; + _pulls.refresher = ^{ + refreshedOnMain = NSThread.isMainThread; + [store setOrigin:CGPointMake(100, 50) forDisplay:0]; + [refreshed fulfill]; + }; + + BGSRNSecureRectanglePulls *pulls = _pulls; + // Async, not sync: GCD may run a sync block on the calling (main) thread. + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + XCTAssertFalse(NSThread.isMainThread); + [pulls pullForDisplay:0]; + [pulled fulfill]; + }); + [self waitForExpectations:@[ pulled, refreshed ] timeout:2]; + + XCTAssertTrue(refreshedOnMain, @"the refresh reads UIKit, so it must run on main"); + XCTAssertEqualObjects(BGSRNServedCoordinates([_pulls pullForDisplay:0]), + (@[@110, @70, @130, @90])); +} + +- (void)testSharedPullsAreOneInstance { + XCTAssertTrue(BGSRNSecureRectanglePulls.shared == BGSRNSecureRectanglePulls.shared); +} + +@end diff --git a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m index 714d2f9a..24b515c4 100644 --- a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m +++ b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m @@ -30,7 +30,8 @@ - (void)setUp { for (NSUInteger i = 0; i < count; i++) { int32_t value = 0; [data getBytes:&value range:NSMakeRange(i * sizeof(int32_t), sizeof(int32_t))]; - [out addObject:@(CFSwapInt32LittleToHost((uint32_t)value))]; + // Signed, as the SDK reads them: an edge may be negative. + [out addObject:@((int32_t)CFSwapInt32LittleToHost((uint32_t)value))]; } return out; } @@ -144,6 +145,123 @@ - (void)testIgnoresACoordinateListThatIsNotWholeRectangles { XCTAssertEqualObjects(packed[1], @0, @"a rejected write must not publish anything"); } +#pragma mark - The window's place on the screen + +/// JS measures in its window; the SDK draws in the screen. A window at +/// {100, 50} on the screen (Stage Manager, the right of Split View) puts the +/// view JS measured at {10, 20} at {110, 70}. +- (void)testServesTheRectanglesMovedByTheOrigin { + const int32_t rects[] = {10, 20, 30, 40}; + [_store setCoordinates:rects count:4 forDisplay:0]; + + [_store setOrigin:CGPointMake(100, 50) forDisplay:0]; + + NSArray *packed = [self unpack:[_store snapshotForDisplay:0]]; + XCTAssertEqualObjects([packed subarrayWithRange:NSMakeRange(2, 4)], + (@[@110, @70, @130, @90])); +} + +/// The origin is usually read before JS has measured anything. +- (void)testAnOriginRecordedFirstAppliesToWhatIsPublishedLater { + [_store setOrigin:CGPointMake(100, 50) forDisplay:0]; + const int32_t rects[] = {10, 20, 30, 40}; + + [_store setCoordinates:rects count:4 forDisplay:0]; + + NSArray *packed = [self unpack:[_store snapshotForDisplay:0]]; + XCTAssertEqualObjects([packed subarrayWithRange:NSMakeRange(2, 4)], + (@[@110, @70, @130, @90])); +} + +/// A window dragged across the screen moves nothing JS measures: only the +/// origin changes, and the SDK must re-read the rectangles. +- (void)testMovesTheVersionWhenTheOriginMoves { + const int32_t rects[] = {10, 20, 30, 40}; + [_store setCoordinates:rects count:4 forDisplay:0]; + NSNumber *before = [self unpack:[_store snapshotForDisplay:0]][0]; + + [_store setOrigin:CGPointMake(100, 50) forDisplay:0]; + + XCTAssertNotEqualObjects(before, [self unpack:[_store snapshotForDisplay:0]][0]); +} + +/// The origin is re-read on every pull; the same place must not make the SDK +/// re-read the rectangles every time. +- (void)testHoldsTheVersionWhenTheOriginStaysPut { + const int32_t rects[] = {10, 20, 30, 40}; + [_store setCoordinates:rects count:4 forDisplay:0]; + [_store setOrigin:CGPointMake(100, 50) forDisplay:0]; + NSNumber *settled = [self unpack:[_store snapshotForDisplay:0]][0]; + + [_store setOrigin:CGPointMake(100, 50) forDisplay:0]; + + XCTAssertEqualObjects(settled, [self unpack:[_store snapshotForDisplay:0]][0]); +} + +/// Before anything is secured the display reports the empty set at its +/// initial version, wherever the window is. +- (void)testAnOriginAloneLeavesTheEmptySetAsItWas { + NSNumber *before = [self unpack:[_store snapshotForDisplay:0]][0]; + + [_store setOrigin:CGPointMake(100, 50) forDisplay:0]; + + NSArray *packed = [self unpack:[_store snapshotForDisplay:0]]; + XCTAssertEqualObjects(packed[0], before); + XCTAssertEqualObjects(packed[1], @0); +} + +/// A window can sit at half a point. Rounding the edges to the nearest point +/// could pull one inside the view and leave a strip of it in the clear. +- (void)testAFractionalOriginOnlyEverGrowsTheRectangle { + const int32_t rects[] = {10, 20, 30, 40}; + [_store setCoordinates:rects count:4 forDisplay:0]; + + [_store setOrigin:CGPointMake(0.5, 71.5) forDisplay:0]; + + NSArray *packed = [self unpack:[_store snapshotForDisplay:0]]; + XCTAssertEqualObjects([packed subarrayWithRange:NSMakeRange(2, 4)], + (@[@10, @91, @31, @112])); +} + +/// A window dragged partly off the top and left of the screen: the edges +/// still round outward. +- (void)testANegativeOriginOnlyEverGrowsTheRectangle { + const int32_t rects[] = {10, 20, 30, 40}; + [_store setCoordinates:rects count:4 forDisplay:0]; + + [_store setOrigin:CGPointMake(-0.5, -30.5) forDisplay:0]; + + NSArray *packed = [self unpack:[_store snapshotForDisplay:0]]; + XCTAssertEqualObjects([packed subarrayWithRange:NSMakeRange(2, 4)], + (@[@9, @(-11), @30, @10])); +} + +/// Wrapping would throw an edge to the other end of the range. +- (void)testEdgesSaturateRatherThanWrap { + const int32_t rects[] = {INT32_MIN + 1, 0, INT32_MAX - 1, 10}; + [_store setCoordinates:rects count:4 forDisplay:0]; + + [_store setOrigin:CGPointMake(-10, 0) forDisplay:0]; + XCTAssertEqualObjects([self unpack:[_store snapshotForDisplay:0]][2], @(INT32_MIN)); + + [_store setOrigin:CGPointMake(10, 0) forDisplay:0]; + XCTAssertEqualObjects([self unpack:[_store snapshotForDisplay:0]][4], @(INT32_MAX)); +} + +- (void)testAnOriginMovesOnlyItsOwnDisplay { + const int32_t rects[] = {10, 20, 30, 40}; + [_store setCoordinates:rects count:4 forDisplay:0]; + [_store setCoordinates:rects count:4 forDisplay:1]; + + [_store setOrigin:CGPointMake(100, 50) forDisplay:0]; + + NSArray *other = [self unpack:[_store snapshotForDisplay:1]]; + XCTAssertEqualObjects([other subarrayWithRange:NSMakeRange(2, 4)], + (@[@10, @20, @30, @40])); +} + +#pragma mark - Identity + /// The shared store outlives any one wrapper: the init provider registers one /// before launch and setWrapperInfo swaps in another, and the regions the app /// marked secret must survive that. diff --git a/packages/react-native/src/NativeBugsee.ts b/packages/react-native/src/NativeBugsee.ts index 6576ec0a..df5f4b8c 100644 --- a/packages/react-native/src/NativeBugsee.ts +++ b/packages/react-native/src/NativeBugsee.ts @@ -49,7 +49,9 @@ export interface Spec extends TurboModule { setWrapperInfo(identity: UnsafeObject): void; /** * Publishes the regions the SDK must not record, for one display, as a flat - * list of four-number rectangles: `[left, top, right, bottom, ...]`. + * list of four-number rectangles: `[left, top, right, bottom, ...]`, in the + * React root's window (`measureInWindow`). Native moves them onto the + * screen, where the SDK wants them, by the window's place there. * * Synchronous and fire-and-forget. The SDK PULLS these 2-3 times a second * from its own thread; a promise would put a JS round trip on a path that diff --git a/packages/react-native/src/secure/BugseeSecure.tsx b/packages/react-native/src/secure/BugseeSecure.tsx index 37dc547d..dd0ea546 100644 --- a/packages/react-native/src/secure/BugseeSecure.tsx +++ b/packages/react-native/src/secure/BugseeSecure.tsx @@ -24,8 +24,11 @@ export interface BugseeSecureProps extends ViewProps { * Fails closed: a measurement that throws or is rejected leaves the last * rectangle published. Only unmounting or `enabled={false}` removes it. * - * The rectangle is in the main React root's window. Android adds that root's - * display origin to `measureInWindow`. A React Native `` is its own + * The rectangle is in the main React root's window. Native moves it onto the + * screen before the SDK reads it: Android by that root's display origin, iOS + * by the window's place on its screen (Stage Manager, Split View, iPhone Duo + * side by side), re-read on the SDK's pulls as the window moves. A React + * Native `` is its own * window (an Android `Dialog`), so a secure view inside one is not placed on * the sheet. Same limit for `managed` nodes measured the same way. * From f49bfcace5c9a67ec58dcbf4bb6004802cc75f51 Mon Sep 17 00:00:00 2001 From: Denis Sheikherev Date: Fri, 2 Oct 2026 15:25:39 +0700 Subject: [PATCH 2/6] fix(ios): place the managed view tree where the SDK places its native nodes The vh origin was the hosting window's frame.origin, the offset the SDK used to add to every native node. The SDK now places its nodes in the frame it records, on iOS every window of the app at its place on the screen, so in a Stage Manager window or the right-hand one side by side the React tree sat that far from the native one: frame.origin is the window's place in its scene, {0, 0} there. The origin is now BGSRNWindowRecordedOrigin, the same one that moves the secure rectangles. The tests compare a node plus the origin with the SDK's own placement. view-tree e2e passes on iOS 27 and 26.5 simulators; the simulator's scene fills its screen, so the old and the new origin agree there and only a device with a window away from the screen's origin tells them apart. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/react-native/ios/BugseeModule.mm | 11 ++-- .../BugseeRNSupport/BGSRNReactWindow.m | 7 ++- .../include/BGSRNReactWindow.h | 24 ++++---- .../BGSRNReactWindowTests.m | 59 +++++++++++-------- 4 files changed, 56 insertions(+), 45 deletions(-) diff --git a/packages/react-native/ios/BugseeModule.mm b/packages/react-native/ios/BugseeModule.mm index b28a9b68..9a821f8f 100644 --- a/packages/react-native/ios/BugseeModule.mm +++ b/packages/react-native/ios/BugseeModule.mm @@ -206,11 +206,12 @@ static void BGSRNSetWrapper(id _Nullable wrapper, BOOL onlyIfAbse } } -/// The `vh` origin: the `frame.origin` (points) of the window hosting the -/// React root, among the windows the SDK's own view-hierarchy walk visits -- -/// the offset the SDK adds to every native node, so the two trees share one -/// space by construction (see `BGSRNReactWindow.h`). nil without one, or off -/// main: the SDK asks on main, and UIKit must not be read anywhere else. +/// The `vh` origin: where the window hosting the React root starts in the +/// frame the SDK records (points), among the windows the SDK's own +/// view-hierarchy walk visits -- the space the SDK places every native node +/// in, so the two trees share one space by construction (see +/// `BGSRNReactWindow.h`). nil without one, or off main: the SDK asks on main, +/// and UIKit must not be read anywhere else. /// /// The root is recognised by class name, not by import: `RCTSurfaceHostingView` /// is the new architecture's root (the template's `RCTRootView` is its diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactWindow.m b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactWindow.m index caaff524..263195c1 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactWindow.m +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactWindow.m @@ -120,9 +120,10 @@ static BOOL BGSRNSdkComposesScreen(void) { if (window == nil) { return nil; } - // `frame.origin`, NOT the window's position in the screen's coordinate - // space: the SDK adds exactly this (BGSCaptureViewHierarchyEngine.m:335-336). - return [NSValue valueWithCGPoint:window.frame.origin]; + // Not `frame.origin` on iOS: that is the window's place in its scene, {0, 0} + // for a Stage Manager window or the right-hand one side by side, while the + // SDK places its nodes on the screen. + return BGSRNWindowRecordedOrigin(window); } NSValue *BGSRNWindowRecordedOrigin(UIWindow *window) { diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactWindow.h b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactWindow.h index e60fe373..277534a2 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactWindow.h +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactWindow.h @@ -5,15 +5,14 @@ NS_ASSUME_NONNULL_BEGIN /// Where the `vh` request's origin comes from: the one number that puts the /// React tree in the same space as the SDK's native tree. /// -/// The SDK's view-hierarchy engine (SDK `0d9c9d0a3`, -/// `BGSCaptureViewHierarchyEngine.m:118-171`) walks the key window's scene -/// windows plus the key window (`BGSTrackerApplication.m:219-320`), and places -/// every node at `[view.window convertRect:frame fromView:view.superview]` plus -/// `view.window.frame.origin` (`:334-336`). JS measures with `measureInWindow` -/// -- window-relative, like the first term -- so the origin it must add is the -/// `frame.origin` of the window that hosts the React root. Equal by -/// construction, whatever UIKit answers for the screen (Split View, Slide -/// Over, Stage Manager). +/// The SDK's view-hierarchy engine places every node in the frame it records +/// (`+[BGSTrackerApplication captureRectForRect:inView:]`, +/// `BGSCaptureViewHierarchyEngine.m` `dumpView:`): on iOS every window of the +/// app at its place on the screen (iPad Stage Manager and Split View, iPhone +/// Duo side by side). JS measures with `measureInWindow` -- window-relative -- +/// so the origin it must add is where the window hosting the React root starts +/// in that frame (`BGSRNWindowRecordedOrigin`), the same origin that moves the +/// secure rectangles (`BGSRNReactRootOriginTracker`). /// /// Everything here reads UIKit, so it must run on main. @@ -49,9 +48,10 @@ FOUNDATION_EXPORT UIWindow *_Nullable BGSRNWindowHostingReactRoot(UIWindow *_Nul BOOL (^isReactRoot)(UIView *view), NSUInteger budget); -/// That window's `frame.origin` (points), boxed as a `CGPoint`; nil when no -/// window hosts the React root -- a request then answers `by=no-origin` -/// rather than a tree offset by a guess. +/// Where that window starts in the frame the SDK records +/// (`BGSRNWindowRecordedOrigin`), boxed as a `CGPoint`; nil when no window +/// hosts the React root, or it is on no screen -- a request then answers +/// `by=no-origin` rather than a tree offset by a guess. FOUNDATION_EXPORT NSValue *_Nullable BGSRNReactRootOrigin(UIWindow *_Nullable keyWindow, NSArray *windows, BOOL (^isReactRoot)(UIView *view)); diff --git a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactWindowTests.m b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactWindowTests.m index 95e7ecd3..78527ffb 100644 --- a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactWindowTests.m +++ b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactWindowTests.m @@ -10,10 +10,10 @@ @interface BGSRNFakeReactRoot : UIView @implementation BGSRNFakeReactRoot @end -/// The `vh` origin must be the offset the SDK itself adds to every native -/// node -- the hosting window's `frame.origin` -/// (`BGSCaptureViewHierarchyEngine.m:334-336`) -- so the React tree and the -/// native tree share one space by construction. +/// The `vh` origin must put the React tree where the SDK places its native +/// nodes -- on iOS on the screen, through the screen's fixed space +/// (`+[BGSTrackerApplication screenRectForRect:inView:]`) -- so the two trees +/// share one space by construction. @interface BGSRNReactWindowTests : XCTestCase @end @@ -44,19 +44,39 @@ - (UIWindow *)windowAt:(CGRect)frame hostingAtDepth:(NSUInteger)depth { return window; } -- (void)testTheOriginIsTheHostingWindowsFrameOrigin { +/// The SDK's placement of `rect` in `view` on iOS, as +/// `+[BGSTrackerApplication screenRectForRect:inView:]` computes it. +- (CGRect)sdkScreenRectForRect:(CGRect)rect inView:(UIView *)view { + UIWindow *window = [view isKindOfClass:UIWindow.class] ? (UIWindow *)view : view.window; + UIScreen *screen = window.windowScene.screen; + id fixedSpace = screen.fixedCoordinateSpace; + return [fixedSpace convertRect:[view convertRect:rect toCoordinateSpace:fixedSpace] + toCoordinateSpace:screen.coordinateSpace]; +} + +- (void)skipWithoutAScreenFor:(UIWindow *)window { + if (window.windowScene.screen == nil) { + XCTSkip(@"The test runner put the window on no screen"); + } +} + +- (void)testTheOriginIsWhereTheHostingWindowStartsOnTheScreen { UIWindow *key = [self windowAt:CGRectMake(0, 0, 390, 844) hostingAtDepth:NSNotFound]; UIWindow *hosting = [self windowAt:CGRectMake(120.5, 64, 300, 400) hostingAtDepth:3]; + [self skipWithoutAScreenFor:hosting]; NSValue *origin = BGSRNReactRootOrigin(key, @[ key, hosting ], _isReactRoot); - XCTAssertEqualObjects(origin, [NSValue valueWithCGPoint:CGPointMake(120.5, 64)]); + XCTAssertEqualObjects(origin, BGSRNWindowRecordedOrigin(hosting)); + XCTAssertTrue(CGPointEqualToPoint(origin.CGPointValue, + [self sdkScreenRectForRect:hosting.bounds inView:hosting].origin)); } /// What JS computes (`measureInWindow` + origin) is what the SDK computes for -/// the same view (window-relative rect + `window.frame.origin`). +/// the same view (its rect on the screen). - (void)testANodePlusTheOriginLandsWhereTheSdkPutsIt { UIWindow *window = [self windowAt:CGRectMake(40, 30, 300, 400) hostingAtDepth:0]; + [self skipWithoutAScreenFor:window]; UIView *container = [[UIView alloc] initWithFrame:CGRectMake(10, 20, 200, 200)]; UIView *view = [[UIView alloc] initWithFrame:CGRectMake(5, 7, 50, 60)]; [container addSubview:view]; @@ -64,28 +84,17 @@ - (void)testANodePlusTheOriginLandsWhereTheSdkPutsIt { const CGPoint origin = BGSRNReactRootOrigin(window, @[ window ], _isReactRoot).CGPointValue; const CGRect inWindow = [view convertRect:view.bounds toView:nil]; - CGRect sdk = [view.window convertRect:view.frame fromView:view.superview]; - sdk.origin.x += view.window.frame.origin.x; - sdk.origin.y += view.window.frame.origin.y; + const CGRect sdk = [self sdkScreenRectForRect:view.frame inView:view.superview]; - XCTAssertEqual(inWindow.origin.x + origin.x, sdk.origin.x); - XCTAssertEqual(inWindow.origin.y + origin.y, sdk.origin.y); - XCTAssertEqual(sdk.origin.x, 55); - XCTAssertEqual(sdk.origin.y, 57); + XCTAssertEqualWithAccuracy(inWindow.origin.x + origin.x, sdk.origin.x, 0.001); + XCTAssertEqualWithAccuracy(inWindow.origin.y + origin.y, sdk.origin.y, 0.001); } -/// Not the window's position in the screen's coordinate space, which can -/// differ from `frame.origin` (iPad multitasking; here, a transformed window, -/// the one case a unit test can construct): the SDK adds `frame.origin`. -- (void)testTheOriginIsTheFrameOriginNotTheScreenSpacePosition { - UIWindow *window = [self windowAt:CGRectMake(10, 20, 100, 200) hostingAtDepth:0]; - window.transform = CGAffineTransformMakeRotation(M_PI); - const CGPoint screenSpace = [window convertPoint:CGPointZero toCoordinateSpace:window.screen.coordinateSpace]; - XCTAssertFalse(CGPointEqualToPoint(screenSpace, window.frame.origin), @"the fixture must tell the two apart"); - - NSValue *origin = BGSRNReactRootOrigin(window, @[ window ], _isReactRoot); +- (void)testAHostingWindowOnNoScreenIsNoOrigin { + UIWindow *window = [self windowAt:CGRectMake(40, 30, 300, 400) hostingAtDepth:0]; + window.windowScene = nil; - XCTAssertEqualObjects(origin, [NSValue valueWithCGPoint:window.frame.origin]); + XCTAssertNil(BGSRNReactRootOrigin(window, @[ window ], _isReactRoot)); } - (void)testTheKeyWindowIsPreferredWhenSeveralHost { From 4af4f554f095a92da8e2c71c8e7bf686b9864a79 Mon Sep 17 00:00:00 2001 From: Denis Sheikherev Date: Fri, 2 Oct 2026 15:25:39 +0700 Subject: [PATCH 3/6] fix(ios): find the React root's window the way the SDK picks and walks windows BGSRNSdkKeyWindow and BGSRNSdkWalkedWindows copied the SDK's earlier logic: the key window chosen by isKeyWindow over connectedScenes, which since the iOS 15 SDK every scene's key window reports, and only the key scene's windows walked. The SDK now takes the application's key window while its scene is in the foreground, the window the user brought forward last, and where it composes the app's windows on the screen it records and walks the windows of every foreground scene on that screen. Mirroring that keeps the React root found in a window the SDK records. Not unit tested: the test runner has no application and no scenes. The Support tests, the example build and the e2e above all ran with this change. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../BugseeRNSupport/BGSRNReactWindow.m | 56 +++++++++++++++---- .../include/BGSRNReactWindow.h | 24 +++++--- 2 files changed, 60 insertions(+), 20 deletions(-) diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactWindow.m b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactWindow.m index 263195c1..18e234ce 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactWindow.m +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactWindow.m @@ -2,11 +2,39 @@ const NSUInteger BGSRNReactRootSearchBudget = 2000; +/// Whether the SDK composes the app's windows on the screen: iOS, and not an +/// iPhone or iPad app running on a Mac (`+[BGSTrackerApplication +/// capturesAppScreen]`). +static BOOL BGSRNSdkComposesScreen(void) { +#if TARGET_OS_MACCATALYST + return NO; +#else + return !NSProcessInfo.processInfo.isiOSAppOnMac; +#endif +} + +/// A window scene the user can see: the SDK leaves background scenes out, as +/// they keep reporting their last place on a screen. +static BOOL BGSRNIsForeground(UIScene *scene) { + return scene.activationState == UISceneActivationStateForegroundActive || + scene.activationState == UISceneActivationStateForegroundInactive; +} + UIWindow *BGSRNSdkKeyWindow(void) { UIApplication *application = UIApplication.sharedApplication; if (application == nil) { return nil; } + // Since the iOS 15 SDK every scene's key window reports isKeyWindow, so with + // two of the app's scenes on screen the loop below cannot tell which one the + // user is in; the application's key window follows the one brought forward. +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + UIWindow *applicationKeyWindow = application.keyWindow; +#pragma clang diagnostic pop + if (applicationKeyWindow.windowScene != nil && BGSRNIsForeground(applicationKeyWindow.windowScene)) { + return applicationKeyWindow; + } UIWindow *fallback = nil; UIWindow *stableForeground = nil; UIWindow *markedKey = nil; @@ -49,29 +77,33 @@ NSDictionary *manifest = [NSBundle.mainBundle objectForInfoDictionaryKey:@"UIApplicationSceneManifest"]; hasSceneManifest = [manifest isKindOfClass:NSDictionary.class] && manifest.count > 0; }); + UIApplication *application = UIApplication.sharedApplication; + UIScreen *screen = keyWindow.windowScene.screen; + if (hasSceneManifest && BGSRNSdkComposesScreen() && application.connectedScenes.count > 1 && + screen != nil) { + NSMutableArray *composed = [NSMutableArray array]; + for (UIScene *scene in application.connectedScenes) { + if ([scene isKindOfClass:UIWindowScene.class] && BGSRNIsForeground(scene) && + ((UIWindowScene *)scene).screen == screen) { + [composed addObjectsFromArray:((UIWindowScene *)scene).windows]; + } + } + if (composed.count > 0) { + return composed; + } + } NSArray *windows = nil; if (hasSceneManifest) { windows = keyWindow.windowScene.windows; } else { #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wdeprecated-declarations" - windows = UIApplication.sharedApplication.windows; + windows = application.windows; #pragma clang diagnostic pop } return windows ?: @[]; } -/// Whether the SDK composes the app's windows on the screen: iOS, and not an -/// iPhone or iPad app running on a Mac (`+[BGSTrackerApplication -/// capturesAppScreen]`). -static BOOL BGSRNSdkComposesScreen(void) { -#if TARGET_OS_MACCATALYST - return NO; -#else - return !NSProcessInfo.processInfo.isiOSAppOnMac; -#endif -} - /// Breadth-first: a React root is near the top of its window, and a deep /// native subtree beside it should not be searched first. static UIView *ReactRootInWindow(UIWindow *window, BOOL (^isReactRoot)(UIView *), NSUInteger budget) { diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactWindow.h b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactWindow.h index 277534a2..8f51226b 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactWindow.h +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactWindow.h @@ -21,17 +21,25 @@ NS_ASSUME_NONNULL_BEGIN /// only keeps a pathological native hierarchy from costing the main thread. FOUNDATION_EXPORT const NSUInteger BGSRNReactRootSearchBudget; -/// The key window exactly as the SDK picks it (`BGSTrackerApplication.m: -/// 219-264`): among window scenes with a key window, the foreground-active -/// one whose key window `isKeyWindow`, else the first foreground-active one, -/// else the last scene's key window seen, else the application's own. Not unit +/// The key window exactly as the SDK picks it (`+[BGSTrackerApplication +/// resolveKeyWindow]`): the application's key window while its scene is in the +/// foreground -- the window the user brought forward last, when several of the +/// app's scenes are on screen -- else, among window scenes with a key window, +/// the foreground-active one whose key window `isKeyWindow`, else the first +/// foreground-active one, else the last scene's key window seen. Not unit /// tested: it needs connected scenes. FOUNDATION_EXPORT UIWindow *_Nullable BGSRNSdkKeyWindow(void); -/// The windows the SDK walks for that key window (`BGSTrackerApplication.m: -/// 185-210, 295-320`): its scene's windows when the app declares a -/// `UIApplicationSceneManifest`, else `-[UIApplication windows]` -- which is -/// the React Native template's case: it declares no manifest. +/// The windows the SDK records and walks for that key window +/// (`BGSWindowsToRecord`, `+[BGSTrackerApplication captureWindows]`). Where +/// the SDK composes the app's windows on the screen -- iOS, not on a Mac -- +/// and the app declares a `UIApplicationSceneManifest` and has more than one +/// scene connected: the windows of every foreground window scene on the key +/// window's screen. Else its scene's windows with a manifest, or +/// `-[UIApplication windows]` without one -- which is the React Native +/// template's case. The SDK orders the scenes back to front; this list is for +/// finding a window, so it keeps `connectedScenes` order. Not unit tested +/// either: the test runner has no application and no scenes. FOUNDATION_EXPORT NSArray *BGSRNSdkWalkedWindows(UIWindow *_Nullable keyWindow); /// The first view `isReactRoot` accepts, searching each window's tree From 99012d70e1e2d8fae05b789ed37cac2d5f97cb9e Mon Sep 17 00:00:00 2001 From: Denis Sheikherev Date: Fri, 2 Oct 2026 15:53:19 +0700 Subject: [PATCH 4/6] fix(ios): read the window's place before publishing secure rectangles setSecureRectangles wrote the rectangles to the store on the JS thread and only then asked main to read where the React root's window sits on the screen. The SDK pulls on main, so a pull between the two got the new rectangles moved by the origin the store held before: CGPointZero until the first read. For a window away from the screen's origin (Stage Manager, Split View, an iPad scene not at {0, 0}) a region JS had just secured was recorded in the clear for that frame. The tracker now takes the publish: on main, it reads the window's place first and then writes the rectangles, so no pull sees one without the other. Android has no such gap; it reads its origin on host resume and on every layout of the root, before JS can measure anything. A test reads the store from inside the origin read and fails if the rectangles are already there. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/react-native/ios/BugseeModule.mm | 21 ++++++------ .../BGSRNReactRootOriginTracker.m | 7 ++++ .../include/BGSRNReactRootOriginTracker.h | 12 +++++++ .../BGSRNReactRootOriginTrackerTests.m | 32 +++++++++++++++++++ 4 files changed, 61 insertions(+), 11 deletions(-) diff --git a/packages/react-native/ios/BugseeModule.mm b/packages/react-native/ios/BugseeModule.mm index 9a821f8f..6dc46bdb 100644 --- a/packages/react-native/ios/BugseeModule.mm +++ b/packages/react-native/ios/BugseeModule.mm @@ -464,24 +464,23 @@ - (void)setSecureRectangles:(double)display // is. Rounding rather than truncating: the JS side has already rounded each // edge outwards, and truncating would pull an edge back inside the region it // was widened to cover. - int32_t *flat = count > 0 ? (int32_t *)malloc(count * sizeof(int32_t)) : NULL; - if (count > 0 && flat == NULL) { + NSMutableData *flat = [NSMutableData dataWithLength:count * sizeof(int32_t)]; + if (flat == nil) { return; } + int32_t *values = (int32_t *)flat.mutableBytes; for (NSUInteger i = 0; i < count; i++) { - flat[i] = (int32_t)llround([coordinates[i] doubleValue]); + values[i] = (int32_t)llround([coordinates[i] doubleValue]); } - [BGSRNSecureRectangles.shared setCoordinates:flat - count:count - forDisplay:(NSInteger)display]; - free(flat); - // JS measured in the React root's window; where that window sits on the - // screen is read on main, as Android re-reads its root's display origin on - // every publish. + // JS measured in the React root's window. The tracker reads where that + // window sits on the screen and only then writes the rectangles, both on + // main, where the SDK pulls: no pull ever serves them at an origin not yet + // read (see -[BGSRNReactRootOriginTracker publishCoordinates:forDisplay:]). BGSRNReactRootOriginTracker *tracker = BGSRNSecureOriginTracker(); + const NSInteger target = (NSInteger)display; BGSRNRunOnMain(^{ - [tracker refreshFindingTheRoot]; + [tracker publishCoordinates:flat forDisplay:target]; }); } diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactRootOriginTracker.m b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactRootOriginTracker.m index 2a95de44..f516faa7 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactRootOriginTracker.m +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactRootOriginTracker.m @@ -31,6 +31,13 @@ - (void)refresh { [self refreshFinding:NO]; } +- (BOOL)publishCoordinates:(NSData *)coordinates forDisplay:(NSInteger)display { + [self refreshFindingTheRoot]; + return [_store setCoordinates:(const int32_t *)coordinates.bytes + count:coordinates.length / sizeof(int32_t) + forDisplay:display]; +} + - (void)refreshFinding:(BOOL)find { @try { UIView *root = _root; diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactRootOriginTracker.h b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactRootOriginTracker.h index b6c7d091..ea938e9e 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactRootOriginTracker.h +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactRootOriginTracker.h @@ -38,6 +38,18 @@ NS_ASSUME_NONNULL_BEGIN /// records where its window starts. - (void)refreshFindingTheRoot; +/// A JS publish: `-refreshFindingTheRoot`, then `coordinates` (packed int32, +/// four per rectangle, in the root's window) written to the store for +/// `display`. In that order and on main, where the SDK pulls. Written first, +/// the rectangles would be served at the origin the store held before for any +/// pull between the two -- `CGPointZero` until the first one is read -- and a +/// window away from the screen's origin would be recorded in the clear for +/// that frame. Android has no such gap: it reads its origin on host resume and +/// on every layout of the root, before JS can measure anything. +/// +/// @return NO, publishing nothing, when `coordinates` is not whole rectangles. +- (BOOL)publishCoordinates:(NSData *)coordinates forDisplay:(NSInteger)display; + /// For the SDK's pulls: records where the window of the root found last starts /// now, without searching. A window can move (Stage Manager, Split View) with /// nothing published. diff --git a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactRootOriginTrackerTests.m b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactRootOriginTrackerTests.m index a12dc9fa..3e6ccfe6 100644 --- a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactRootOriginTrackerTests.m +++ b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactRootOriginTrackerTests.m @@ -106,6 +106,38 @@ - (void)testAPublishSearchesAgainOnceTheRootHasLeftItsWindow { XCTAssertEqualObjects([self served], (@[@110, @70, @130, @90])); } +/// A publish reads the window's place before it writes: a pull in between +/// must never get the new rectangles at an origin not yet read. +- (void)testAPublishReadsThePlaceBeforeItWritesTheRectangles { + BGSRNSecureRectangles *store = [[BGSRNSecureRectangles alloc] init]; + UIView *root = _root; + __block NSArray *servedWhileReading = nil; + BGSRNReactRootOriginTracker *tracker = [[BGSRNReactRootOriginTracker alloc] initWithStore:store + findRoot:^UIView * { + return root; + } + readOrigin:^NSValue *(UIWindow *window) { + servedWhileReading = BGSRNServedCoordinates([store snapshotForDisplay:0]); + return [NSValue valueWithCGPoint:CGPointMake(100, 50)]; + }]; + const int32_t rects[] = {10, 20, 30, 40}; + + XCTAssertTrue([tracker publishCoordinates:[NSData dataWithBytes:rects length:sizeof(rects)] + forDisplay:0]); + + XCTAssertEqualObjects(servedWhileReading, @[], @"written before the origin was read"); + XCTAssertEqualObjects(BGSRNServedCoordinates([store snapshotForDisplay:0]), + (@[@110, @70, @130, @90])); +} + +- (void)testAPublishOfPartRectanglesIsRefused { + const int32_t coordinates[] = {1, 2, 3}; + + XCTAssertFalse([[self tracker] publishCoordinates:[NSData dataWithBytes:coordinates + length:sizeof(coordinates)] + forDisplay:0]); +} + /// A window off its screen for a moment (a scene disconnecting) must not /// throw the regions back to the window's own corner. - (void)testKeepsTheLastOriginWhenThePlaceCannotBeRead { From 73460ac388c365f595e87ff0be7f38f535ad2841 Mon Sep 17 00:00:00 2001 From: Denis Sheikherev Date: Fri, 2 Oct 2026 15:53:31 +0700 Subject: [PATCH 5/6] docs(ios): state the secure-rectangle pull cadence the SDK documents The comments gave two rates for the same pull, "2-3 times a second" (Android's) and "ten times a second". The SDK's contract for secureRectanglesForDisplay: is once per captured frame and per report screenshot, on main, and from the touch filter at most every 100 ms while nothing captures. Every iOS comment now says that. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/react-native/ios/BugseeModule.mm | 2 +- .../Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m | 4 ++-- .../BugseeRNSupport/include/BGSRNReactRootOriginTracker.h | 6 +++--- .../Sources/BugseeRNSupport/include/BGSRNSecureRectangles.h | 6 ++++-- .../BugseeRNSupportTests/BGSRNReactRootOriginTrackerTests.m | 6 +++--- .../BugseeRNSupportTests/BGSRNSecureRectanglePullsTests.m | 3 ++- .../Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m | 6 +++--- 7 files changed, 18 insertions(+), 15 deletions(-) diff --git a/packages/react-native/ios/BugseeModule.mm b/packages/react-native/ios/BugseeModule.mm index 6dc46bdb..8cceaf4a 100644 --- a/packages/react-native/ios/BugseeModule.mm +++ b/packages/react-native/ios/BugseeModule.mm @@ -98,7 +98,7 @@ - (void)onLifecycleEvent:(NSString *)eventType data:(id)data { /// the screen. /// /// Read from the process-wide store rather than from this instance. The SDK -/// pulls 2-3 times a second on the MAIN thread, and the wrapper it pulls +/// pulls on the MAIN thread once per captured frame, and the wrapper it pulls /// through is replaced when `setWrapperInfo` runs — regions the app marked /// secret must survive that swap. See `BGSRNSecureRectangles` for the version /// contract, which is what makes the SDK notice a change at all. The pull diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m index aec7a739..ad5a5a03 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m @@ -63,8 +63,8 @@ @implementation BGSRNSecureRectangles { /// display -> its current version. NSMutableDictionary *_versionsByDisplay; /// Serialises the JS-thread write against the main-thread pull. A plain lock - /// rather than a queue: the pull happens on the SDK's own thread 2-3 times a - /// second and must not be made to hop. + /// rather than a queue: the pull happens on main once per captured frame and + /// must not be made to hop. NSLock *_lock; } diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactRootOriginTracker.h b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactRootOriginTracker.h index ea938e9e..eb412bca 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactRootOriginTracker.h +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactRootOriginTracker.h @@ -16,9 +16,9 @@ NS_ASSUME_NONNULL_BEGIN /// It keeps the React root view it found, weakly, and follows `root.window`, /// which costs nothing and still answers when the root is moved into another /// window. Searching the windows for a root happens only on a JS publish, and -/// only when the root found last has left its window: the SDK's pulls run ten -/// times a second on the main thread, and in an app showing no React root at -/// all (the native screens of a brownfield app) a search would walk every +/// only when the root found last has left its window: the SDK pulls on the +/// main thread once per captured frame, and in an app showing no React root +/// at all (the native screens of a brownfield app) a search would walk every /// window to its budget each time. /// /// Main thread only: it reads UIKit. diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSecureRectangles.h b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSecureRectangles.h index 5076f131..24084630 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSecureRectangles.h +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSecureRectangles.h @@ -6,8 +6,10 @@ NS_ASSUME_NONNULL_BEGIN /// The regions the app has asked Bugsee not to record, in the form the SDK /// pulls them. /// -/// The SDK does not subscribe to changes. It asks, 2-3 times a second and on -/// the MAIN thread, for a packed buffer of little-endian `int32` +/// The SDK does not subscribe to changes. It asks on the MAIN thread, once per +/// captured frame and per report screenshot (and from the touch filter, at +/// most every 100 ms, while nothing captures), for a packed buffer of +/// little-endian `int32` /// `[version, count, left, top, right, bottom, ...]`, and re-reads the /// rectangles only when the version differs from the one it saw last. Two /// properties follow, and both are load-bearing rather than cosmetic: diff --git a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactRootOriginTrackerTests.m b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactRootOriginTrackerTests.m index 3e6ccfe6..439350db 100644 --- a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactRootOriginTrackerTests.m +++ b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactRootOriginTrackerTests.m @@ -57,9 +57,9 @@ - (void)testRecordsTheWindowsPlaceForDisplayZero { XCTAssertEqualObjects([self served], (@[@110, @70, @130, @90])); } -/// The SDK pulls ten times a second on the main thread. With no React root on -/// screen (a brownfield app's native screens) a search there would walk every -/// window to its budget each time. +/// The SDK pulls once per captured frame on the main thread. With no React +/// root on screen (a brownfield app's native screens) a search there would +/// walk every window to its budget each time. - (void)testAPullNeverSearches { BGSRNReactRootOriginTracker *tracker = [self tracker]; diff --git a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglePullsTests.m b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglePullsTests.m index 39a74964..01677dd8 100644 --- a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglePullsTests.m +++ b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglePullsTests.m @@ -3,7 +3,8 @@ #import "BGSRNSecureOriginTestSupport.h" -/// The SDK pulls 2-3 times a second, more while capturing. Each pull keeps the +/// The SDK pulls once per captured frame, and from the touch filter at most +/// every 100 ms while nothing captures. Each pull keeps the /// window's place fresh -- a window moves with nothing published -- but no /// more often than every 100 ms, and it always gets its rectangles. @interface BGSRNSecureRectanglePullsTests : XCTestCase diff --git a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m index 24b515c4..9b27a36d 100644 --- a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m +++ b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m @@ -1,9 +1,9 @@ @import XCTest; @import BugseeRNSupport; -/// The SDK PULLS this buffer 2-3 times a second — on iOS from the MAIN thread — -/// and re-reads the rectangles only when the version differs from the one it -/// saw last. Two properties follow, and both are load-bearing: +/// The SDK PULLS this buffer once per captured frame — on iOS from the MAIN +/// thread — and re-reads the rectangles only when the version differs from the +/// one it saw last. Two properties follow, and both are load-bearing: /// /// * a change MUST move the version, or the SDK goes on redacting the region /// the app has stopped considering secret and, worse, records a newly From 282233b225ca7b4cb17ee229cbc4add79ac0905a Mon Sep 17 00:00:00 2001 From: Denis Sheikherev Date: Fri, 2 Oct 2026 15:53:31 +0700 Subject: [PATCH 6/6] docs(ios): note that the origin tracker follows one React root With React roots in two windows the tracker follows the first one it finds until that one leaves its window, and the rectangles JS measured in the other window are moved by the wrong origin. Android's tracker holds one root too. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../BugseeRNSupport/include/BGSRNReactRootOriginTracker.h | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactRootOriginTracker.h b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactRootOriginTracker.h index eb412bca..5148324c 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactRootOriginTracker.h +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactRootOriginTracker.h @@ -21,6 +21,10 @@ NS_ASSUME_NONNULL_BEGIN /// at all (the native screens of a brownfield app) a search would walk every /// window to its budget each time. /// +/// One root, as on Android: with React roots in two windows, it follows the +/// first one found until that one leaves its window, and the rectangles JS +/// measured in the other window are moved by the wrong origin. +/// /// Main thread only: it reads UIKit. @interface BGSRNReactRootOriginTracker : NSObject