diff --git a/packages/react-native/ios/BugseeModule.mm b/packages/react-native/ios/BugseeModule.mm index d1bd3bf2..8cceaf4a 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 +/// 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. +/// 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 @@ -195,20 +206,18 @@ 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 /// `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 +225,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"; @@ -416,18 +464,24 @@ - (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. 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 publishCoordinates:flat forDisplay:target]; + }); } #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..f516faa7 --- /dev/null +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactRootOriginTracker.m @@ -0,0 +1,63 @@ +#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]; +} + +- (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; + 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..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,13 +77,28 @@ 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 ?: @[]; @@ -63,40 +106,69 @@ /// 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) { 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) { + 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..ad5a5a03 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m @@ -19,14 +19,52 @@ 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; /// 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; } @@ -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..5148324c --- /dev/null +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactRootOriginTracker.h @@ -0,0 +1,68 @@ +#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 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. +/// +/// 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 + +/// @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; + +/// 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. +/// +/// 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..8f51226b 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. @@ -22,32 +21,62 @@ 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 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), 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)); +/// 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..24084630 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 @@ -5,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: @@ -47,8 +50,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..439350db --- /dev/null +++ b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactRootOriginTrackerTests.m @@ -0,0 +1,196 @@ +@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 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]; + + [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 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 { + 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..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 { @@ -121,4 +130,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..01677dd8 --- /dev/null +++ b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglePullsTests.m @@ -0,0 +1,129 @@ +@import XCTest; +@import BugseeRNSupport; + +#import "BGSRNSecureOriginTestSupport.h" + +/// 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 +@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..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 @@ -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. *