From 02499fa5be20d56a65b8a0a165545a62948958b5 Mon Sep 17 00:00:00 2001 From: Denis Sheikherev Date: Tue, 6 Oct 2026 16:50:41 +0700 Subject: [PATCH 1/2] fix(ios): keep secure rectangles and the view tree on windows away from the screen's origin JS measures with `measureInWindow`, in the window's points. The iOS SDK that composes every window of the app on the screen (bugsee-cocoa #170-#172, #186) draws secure rectangles and places native view-tree nodes in screen points, 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) a rectangle moved only by the window's `frame.origin` lands off the view it covers, and the view is recorded in the clear. Every lane now takes the window's place on its screen, read the way the SDK places windows (through the screen's fixed space): the main surface, each lane from #48 (the presented view's origin in its window plus that place), and the `vh` origin. On a Mac the SDK records the key window's scene alone, and the place stays `frame.origin`. The key window and the windows searched follow the SDK's current pick: the application's key window while its scene is in the foreground, and every foreground scene's windows on that screen when there are several. The SDK pulls the rectangles once per captured frame, and the main surface's origin is re-read on every pull. BGSRNReactRootOriginTracker keeps the React root found last, weakly, and reads its window's place; only without one does a pull search, at most once per 0.5 s (a brownfield app's native screens have no root). A JS publish on the main surface and a `vh` request search at once. Needs the iOS SDK release with that composition; ships with the pin bump. Co-Authored-By: Claude Opus 5.5 --- packages/react-native/ios/BugseeModule.mm | 101 +++++++-- .../BGSRNReactRootOriginTracker.m | 63 ++++++ .../BugseeRNSupport/BGSRNReactWindow.m | 123 ++++++++-- .../include/BGSRNReactRootOriginTracker.h | 48 ++++ .../include/BGSRNReactWindow.h | 93 +++++--- .../include/BGSRNSecureRectangles.h | 6 +- .../BGSRNReactRootOriginTrackerTests.m | 210 ++++++++++++++++++ .../BGSRNReactWindowTests.m | 170 ++++++++++---- .../BGSRNSecureRectanglesTests.m | 6 +- packages/react-native/src/NativeBugsee.ts | 4 +- .../react-native/src/secure/BugseeSecure.tsx | 3 +- 11 files changed, 704 insertions(+), 123 deletions(-) create mode 100644 packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactRootOriginTracker.m create mode 100644 packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactRootOriginTracker.h create mode 100644 packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactRootOriginTrackerTests.m diff --git a/packages/react-native/ios/BugseeModule.mm b/packages/react-native/ios/BugseeModule.mm index 693bee8c..981606f2 100644 --- a/packages/react-native/ios/BugseeModule.mm +++ b/packages/react-native/ios/BugseeModule.mm @@ -19,6 +19,7 @@ #import #import #import +#import #import #import #import @@ -44,6 +45,7 @@ #import "BGSRNWrapperChannelHolder.h" #import "BGSRNStatusMapper.h" #import "BGSRNSecureRectangles.h" +#import "BGSRNReactRootOriginTracker.h" #import "BGSRNEventBus.h" #import "BGSRNTokens.h" #import "BGSRNReportHandlerBridge.h" @@ -70,6 +72,7 @@ static NSValue *_Nullable BGSRNReactOrigin(void); static NSValue *_Nullable BGSRNModalSurfaceOrigin(NSInteger surface); static Class _Nullable BGSRNModalHostClass(void); +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 @@ -103,10 +106,11 @@ - (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. @@ -234,20 +238,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; @@ -255,11 +257,24 @@ static void BGSRNSetWrapper(id _Nullable wrapper, BOOL onlyIfAbse surfaceHostingView = NSClassFromString(@"RCTSurfaceHostingView"); legacyRootView = NSClassFromString(@"RCTRootView"); }); - UIWindow *keyWindow = BGSRNSdkKeyWindow(); - return BGSRNReactRootOrigin(keyWindow, BGSRNSdkWalkedWindows(keyWindow), ^BOOL(UIView *view) { - return (surfaceHostingView != Nil && [view isKindOfClass:surfaceHostingView]) || - (legacyRootView != Nil && [view isKindOfClass:legacyRootView]); - }); + return (surfaceHostingView != Nil && [view isKindOfClass:surfaceHostingView]) || + (legacyRootView != Nil && [view isKindOfClass:legacyRootView]); +} + +static NSValue *_Nullable BGSRNReactOrigin(void) { + if (!NSThread.isMainThread) { + return nil; + } + return [BGSRNSecureOriginTracker() origin]; +} + +/// `BGSRNReactOrigin`, searching for the root at once when the one found last +/// is in no window: for a `vh` request, which comes once per walk. +static NSValue *_Nullable BGSRNReactOriginFindingTheRoot(void) { + if (!NSThread.isMainThread) { + return nil; + } + return [BGSRNSecureOriginTracker() originFindingTheRoot]; } /// `RCTModalHostViewComponentView`, resolved by name rather than imported; @@ -291,10 +306,42 @@ static Class _Nullable BGSRNModalHostClass(void) { if (modalHostClass == Nil || surface <= 0) { return nil; } - return BGSRNSecureSurfaceOrigin(BGSRNSecureRectangles.shared, surface, - BGSRNSdkWalkedWindows(BGSRNSdkKeyWindow()), ^BOOL(UIView *view) { - return [view isKindOfClass:modalHostClass]; - }); + return BGSRNSecureSurfaceOrigin( + BGSRNSecureRectangles.shared, surface, BGSRNSdkWalkedWindows(BGSRNSdkKeyWindow()), + ^BOOL(UIView *view) { + return [view isKindOfClass:modalHostClass]; + }, + ^NSValue *_Nullable(UIWindow *window) { + return BGSRNWindowRecordedOrigin(window); + }); +} + +/// Keeps the main surface's origin on the window JS measures in: the iOS +/// peer of Android's `ReactRootOriginTracker` (see +/// `BGSRNReactRootOriginTracker`). Process-wide, like the store it feeds: the +/// window lookup reads only UIKit, nothing of one module. +static BGSRNReactRootOriginTracker *BGSRNSecureOriginTracker(void) { + static BGSRNReactRootOriginTracker *tracker = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + tracker = [[BGSRNReactRootOriginTracker alloc] + initWithFindRoot:^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); + } + clock:^NSTimeInterval { + return NSProcessInfo.processInfo.systemUptime; + }]; + }); + return tracker; } static NSString *const kHandleDeadCode = @"E_REPORT_HANDLE_DEAD"; @@ -959,7 +1006,7 @@ - (void)attachToBridges { }, @"onDataRequest"); } origin:^NSValue *_Nullable { - return BGSRNReactOrigin(); + return BGSRNReactOriginFindingTheRoot(); }]; // After the emitter exists. A network filter installed earlier reads this // pointer when an event arrives; it is nil until then, and a nil module @@ -1064,6 +1111,18 @@ - (void)setSecureRectanglesOnSurface:(double)display surface:key runtime:_secureRuntime]; free(flat); + // The main surface's rectangles: find the React root now, on main, and + // record its window's place, rather than leave the lane failing closed + // until a pull's search (`BGSRNReactRootOriginTracker`) gets to it. + if (published && count > 0 && key == BGSRNSecureMainSurface) { + const NSInteger target = (NSInteger)display; + BGSRNRunOnMain(^{ + NSValue *origin = [BGSRNSecureOriginTracker() originFindingTheRoot]; + if (origin != nil) { + [BGSRNSecureRectangles.shared setOrigin:origin.CGPointValue forDisplay:target]; + } + }); + } // A 's rectangles: name this runtime's lookup of its host, so the // pull places them by this runtime's Modal. An empty publish (the Modal // clearing on unmount) names nothing. 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..50e6d013 --- /dev/null +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactRootOriginTracker.m @@ -0,0 +1,63 @@ +#import "BGSRNReactRootOriginTracker.h" + +const NSTimeInterval BGSRNReactRootSearchMinInterval = 0.5; + +@implementation BGSRNReactRootOriginTracker { + UIView *_Nullable (^_findRoot)(void); + NSValue *_Nullable (^_readOrigin)(UIWindow *window); + NSTimeInterval (^_clock)(void); + /// The React root found last. Weak: a reload replaces it, and the tracker + /// must not keep the old one alive. + __weak UIView *_root; + BOOL _hasSearched; + NSTimeInterval _lastSearch; +} + +- (instancetype)initWithFindRoot:(UIView *_Nullable (^)(void))findRoot + readOrigin:(NSValue *_Nullable (^)(UIWindow *window))readOrigin + clock:(NSTimeInterval (^)(void))clock { + self = [super init]; + if (self) { + _findRoot = [findRoot copy]; + _readOrigin = [readOrigin copy]; + _clock = [clock copy]; + } + return self; +} + +- (NSValue *)origin { + return [self originSearchingAtOnce:NO]; +} + +- (NSValue *)originFindingTheRoot { + return [self originSearchingAtOnce:YES]; +} + +- (NSValue *)originSearchingAtOnce:(BOOL)atOnce { + @try { + UIView *root = _root; + if (root.window == nil && [self claimSearch:atOnce]) { + root = _findRoot(); + _root = root; + } + UIWindow *window = [root isKindOfClass:UIWindow.class] ? (UIWindow *)root : root.window; + return window == nil ? nil : _readOrigin(window); + } @catch (NSException *exception) { + NSLog(@"BugseeRN secure origin lookup threw: %@", NSStringFromClass(exception.class)); + return nil; + } +} + +/// Whether a search may run now: always at once, else not within +/// `BGSRNReactRootSearchMinInterval` of the last one. +- (BOOL)claimSearch:(BOOL)atOnce { + const NSTimeInterval now = _clock(); + if (!atOnce && _hasSearched && now - _lastSearch < BGSRNReactRootSearchMinInterval) { + return NO; + } + _hasSearched = YES; + _lastSearch = now; + return YES; +} + +@end diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactWindow.m b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactWindow.m index 61866813..2185d006 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactWindow.m +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactWindow.m @@ -4,11 +4,39 @@ const NSUInteger BGSRNModalHostSearchBudget = 10000; +/// 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; @@ -51,13 +79,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 ?: @[]; @@ -65,42 +108,71 @@ /// 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]; } /// A `` host's view controller. Declared here rather than imported: @@ -130,7 +202,7 @@ - (UIViewController *)viewController; return found; } -NSValue *BGSRNModalHostOrigin(UIView *host) { +NSValue *BGSRNModalHostOrigin(UIView *host, NSValue *_Nullable (^windowOrigin)(UIWindow *window)) { if (![host respondsToSelector:@selector(viewController)]) { return nil; } @@ -143,24 +215,29 @@ - (UIViewController *)viewController; if (window == nil) { return nil; } - // For PR #30's rebase: the `window.frame.origin` term here must become the - // same window-placement value #30 gives the main lane, so both lanes share - // one convention. + // The same window placement as the main surface (`BGSRNWindowRecordedOrigin` + // in the module), so both lanes share one convention. + NSValue *placed = windowOrigin(window); + if (placed == nil) { + return nil; + } CGPoint inWindow = [content convertPoint:CGPointZero toView:nil]; - return [NSValue valueWithCGPoint:CGPointMake(inWindow.x + window.frame.origin.x, inWindow.y + window.frame.origin.y)]; + CGPoint place = placed.CGPointValue; + return [NSValue valueWithCGPoint:CGPointMake(inWindow.x + place.x, inWindow.y + place.y)]; } NSValue *BGSRNSecureSurfaceOrigin(BGSRNSecureRectangles *store, NSInteger surface, NSArray *windows, - BOOL (^isHost)(UIView *)) { + BOOL (^isHost)(UIView *), + NSValue *_Nullable (^windowOrigin)(UIWindow *window)) { UIView *host = [store hostForSurface:surface accepting:^BOOL(id candidate) { return [candidate isKindOfClass:UIView.class] && ((UIView *)candidate).tag == surface && isHost(candidate); }]; if (host != nil) { // The current runtime's own host: not presented yet is no origin, not a // search that could find another runtime's. - return BGSRNModalHostOrigin(host); + return BGSRNModalHostOrigin(host, windowOrigin); } if ([store isHostNamedForSurface:surface]) { // Named but not found: the current runtime's host has not mounted yet @@ -170,5 +247,5 @@ - (UIViewController *)viewController; return nil; } UIView *found = BGSRNUniqueTaggedView(windows, surface, isHost, BGSRNModalHostSearchBudget); - return found == nil ? nil : BGSRNModalHostOrigin(found); + return found == nil ? nil : BGSRNModalHostOrigin(found, windowOrigin); } 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..d6689bb4 --- /dev/null +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactRootOriginTracker.h @@ -0,0 +1,48 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +/// The shortest time between two searches for the React root on the SDK's +/// pulls, in seconds, while no root found earlier is in a window. +FOUNDATION_EXPORT const NSTimeInterval BGSRNReactRootSearchMinInterval; + +/// Where the React root's window starts in the frame the SDK records: the main +/// surface's origin, what moves its secure rectangles and the `vh` tree onto +/// the screen. The iOS peer of Android's `ReactRootOriginTracker`. +/// +/// The SDK pulls the secure rectangles on the main thread once per captured +/// frame and per report screenshot, and the main surface's origin is re-read +/// on each pull, so a window that moves (Stage Manager, Split View, iPhone Duo +/// side by side) is followed with nothing published. A search walks every +/// window the SDK walks, to its budget when there is no React root (a +/// brownfield app's native screens), so the tracker keeps the root it found +/// last, weakly -- a reload replaces it and it must not be kept alive -- and +/// only reads its window's place while it is in one. Without such a root a +/// pull searches at most once per `BGSRNReactRootSearchMinInterval`; a JS +/// publish or a `vh` request searches at once. +/// +/// Never guesses: nil when there is no root, or its place cannot be read, and +/// the caller keeps the origin it has. Main thread only. An exception from +/// either block is logged and read as nil. +@interface BGSRNReactRootOriginTracker : NSObject + +/// @param findRoot searches for the React root; nil when there is none. +/// @param readOrigin the place of the window holding the root. +/// @param clock seconds on a monotonic clock, for the pulls' search interval. +- (instancetype)initWithFindRoot:(UIView *_Nullable (^)(void))findRoot + readOrigin:(NSValue *_Nullable (^)(UIWindow *window))readOrigin + clock:(NSTimeInterval (^)(void))clock NS_DESIGNATED_INITIALIZER; +- (instancetype)init NS_UNAVAILABLE; + +/// For the SDK's pull: the origin of the root found last while it is in a +/// window. Without one, searches, at most once per +/// `BGSRNReactRootSearchMinInterval`. +- (nullable NSValue *)origin; + +/// For a JS publish or a `vh` request: the same, but searching now whenever +/// the root found last is in no window. +- (nullable NSValue *)originFindingTheRoot; + +@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 dd3f8237..554a151e 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactWindow.h +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactWindow.h @@ -7,15 +7,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. @@ -24,34 +23,64 @@ 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); + /// How many views `BGSRNUniqueTaggedView` inspects per window before moving /// on to the next. A `` host sits inside the React tree, deeper than a /// root, so this is larger than `BGSRNReactRootSearchBudget`; it still keeps @@ -73,14 +102,18 @@ FOUNDATION_EXPORT UIView *_Nullable BGSRNUniqueTaggedView(NSArray *w /// Where `measureInWindow`'s (0, 0) sits on the screen, in points, inside the /// `` whose host component view is `host`: the origin of the host's -/// presented view controller's view in its window, plus the window's -/// `frame.origin`. Fabric inserts the Modal's children into that view +/// presented view controller's view in its window, plus `windowOrigin` of +/// that window: where it starts in the frame the SDK records +/// (`BGSRNWindowRecordedOrigin`), the same window placement the main surface +/// uses. Fabric inserts the Modal's children into that view /// (`RCTModalHostViewComponentView`), and `measureInWindow` inside a Modal /// stops at the `ModalHostView` node with an identity transform, so this is /// the window's origin for a full-screen Modal and inset for a `pageSheet` or /// `formSheet` one. nil when `host` has no `viewController`, or that view is -/// not loaded or has no window (the Modal is not presented). -FOUNDATION_EXPORT NSValue *_Nullable BGSRNModalHostOrigin(UIView *host); +/// not loaded or has no window (the Modal is not presented), or +/// `windowOrigin` has no place for the window (it is on no screen). +FOUNDATION_EXPORT NSValue *_Nullable BGSRNModalHostOrigin(UIView *host, + NSValue *_Nullable (^windowOrigin)(UIWindow *window)); /// The screen origin of `surface`, a `` lane in `store`. Read from the /// current runtime's host (`hostForSurface:accepting:`, which takes a view @@ -91,10 +124,12 @@ FOUNDATION_EXPORT NSValue *_Nullable BGSRNModalHostOrigin(UIView *host); /// registry), the one view `isHost` accepts with that tag in `windows` /// (`BGSRNUniqueTaggedView`); nil when there are none or several. A nil /// leaves the lane's origin as it was: unknown for a new lane, which is -/// served as the whole display. Main thread only. +/// served as the whole display. `windowOrigin` places the host's window, as +/// for `BGSRNModalHostOrigin`. Main thread only. FOUNDATION_EXPORT NSValue *_Nullable BGSRNSecureSurfaceOrigin(BGSRNSecureRectangles *store, NSInteger surface, NSArray *windows, - BOOL (^isHost)(UIView *view)); + BOOL (^isHost)(UIView *view), + NSValue *_Nullable (^windowOrigin)(UIWindow *window)); 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 292b790a..49306ad7 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSecureRectangles.h +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSecureRectangles.h @@ -14,8 +14,10 @@ FOUNDATION_EXPORT const int32_t BGSRNSecureFallbackDisplaySize; /// 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 new file mode 100644 index 00000000..0bf0e52f --- /dev/null +++ b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactRootOriginTrackerTests.m @@ -0,0 +1,210 @@ +@import XCTest; +@import UIKit; +@import BugseeRNSupport; + +/// The tracker gives where the React root's window sits on the screen, so the +/// main surface's secure rectangles and the `vh` tree land where the SDK draws. +/// 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, which come +/// once per captured frame. +@interface BGSRNReactRootOriginTrackerTests : XCTestCase +@end + +@implementation BGSRNReactRootOriginTrackerTests { + UIWindow *_window; + UIView *_root; + NSUInteger _searches; + NSValue *_origin; + NSTimeInterval _now; +} + +- (void)setUp { + [super setUp]; + _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)]; + _now = 1000; +} + +/// Finds `_root` (or nothing once it is nil) and reads `_origin` for the +/// window it is in, counting the searches, on a clock the test moves. +- (BGSRNReactRootOriginTracker *)tracker { + __weak __typeof(self) weakSelf = self; + return [[BGSRNReactRootOriginTracker alloc] + initWithFindRoot:^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; + } + clock:^NSTimeInterval { + __typeof(self) strongSelf = weakSelf; + return strongSelf ? strongSelf->_now : 0; + }]; +} + +- (NSValue *)at:(CGFloat)x :(CGFloat)y { + return [NSValue valueWithCGPoint:CGPointMake(x, y)]; +} + +- (void)testGivesTheWindowsPlace { + XCTAssertEqualObjects([[self tracker] originFindingTheRoot], [self at:100 :50]); +} + +/// A window dragged across the screen: a pull re-reads its place through the +/// root it already has, without a search. +- (void)testAPullFollowsTheWindowOfTheRootFoundLast { + BGSRNReactRootOriginTracker *tracker = [self tracker]; + [tracker originFindingTheRoot]; + + _origin = [self at:200 :80]; + + XCTAssertEqualObjects([tracker origin], [self at:200 :80]); + XCTAssertEqual(_searches, 1u); +} + +/// 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 on each pull +/// would walk every window to its budget each time. +- (void)testPullsWithNoRootSearchAtMostOncePerInterval { + _root = nil; + BGSRNReactRootOriginTracker *tracker = [self tracker]; + + XCTAssertNil([tracker origin]); + XCTAssertNil([tracker origin]); + _now += BGSRNReactRootSearchMinInterval / 2; + XCTAssertNil([tracker origin]); + XCTAssertEqual(_searches, 1u); + + _now += BGSRNReactRootSearchMinInterval; + XCTAssertNil([tracker origin]); + XCTAssertEqual(_searches, 2u); +} + +/// A pull still finds a root that was not there before, at the next interval: +/// a main surface with rectangles and no origin serves the whole screen. +- (void)testAPullFindsARootThatAppearedLater { + UIView *root = _root; + _root = nil; + BGSRNReactRootOriginTracker *tracker = [self tracker]; + XCTAssertNil([tracker origin]); + + _root = root; + _now += BGSRNReactRootSearchMinInterval; + + XCTAssertEqualObjects([tracker origin], [self at:100 :50]); +} + +/// A JS publish or a `vh` request does not wait for the interval. +- (void)testFindingTheRootSearchesAtOnce { + UIView *root = _root; + _root = nil; + BGSRNReactRootOriginTracker *tracker = [self tracker]; + XCTAssertNil([tracker origin]); + + _root = root; + + XCTAssertEqualObjects([tracker originFindingTheRoot], [self at:100 :50]); + XCTAssertEqual(_searches, 2u); +} + +- (void)testDoesNotSearchWhileTheRootIsInAWindow { + BGSRNReactRootOriginTracker *tracker = [self tracker]; + [tracker originFindingTheRoot]; + + [tracker originFindingTheRoot]; + [tracker origin]; + + XCTAssertEqual(_searches, 1u); +} + +/// A reload replaces the root; the next search finds the new one. +- (void)testSearchesAgainOnceTheRootHasLeftItsWindow { + BGSRNReactRootOriginTracker *tracker = [self tracker]; + [tracker originFindingTheRoot]; + [_root removeFromSuperview]; + _root = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 10, 10)]; + [_window addSubview:_root]; + + XCTAssertEqualObjects([tracker originFindingTheRoot], [self at:100 :50]); + XCTAssertEqual(_searches, 2u); +} + +/// A window off its screen for a moment (a scene disconnecting) has no place: +/// nil, and the caller keeps the origin it has rather than serve the regions +/// at the window's own corner. +- (void)testNoOriginWhenThePlaceCannotBeRead { + BGSRNReactRootOriginTracker *tracker = [self tracker]; + [tracker originFindingTheRoot]; + + _origin = nil; + + XCTAssertNil([tracker origin]); +} + +- (void)testNoOriginWhenNoRootIsFound { + _root = nil; + + XCTAssertNil([[self tracker] originFindingTheRoot]); +} + +/// The tracker keeps the root weakly: a reload's old root must go. +- (void)testDoesNotKeepTheRootAlive { + BGSRNReactRootOriginTracker *tracker = [self tracker]; + __weak UIView *weakRoot = nil; + @autoreleasepool { + UIView *root = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 10, 10)]; + [_window addSubview:root]; + _root = root; + weakRoot = root; + [tracker originFindingTheRoot]; + [root removeFromSuperview]; + _root = nil; + } + + XCTAssertNil(weakRoot); +} + +/// 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] + initWithFindRoot:^UIView * { + [NSException raise:@"Test" format:@"search"]; + return nil; + } + readOrigin:^NSValue *(UIWindow *window) { + return nil; + } + clock:^NSTimeInterval { + return 0; + }]; + + XCTAssertNoThrow([tracker originFindingTheRoot]); + XCTAssertNil([tracker originFindingTheRoot]); +} + +- (void)testSwallowsAnExceptionFromReadingThePlace { + UIView *root = _root; + BGSRNReactRootOriginTracker *tracker = [[BGSRNReactRootOriginTracker alloc] + initWithFindRoot:^UIView * { + return root; + } + readOrigin:^NSValue *(UIWindow *window) { + [NSException raise:@"Test" format:@"read"]; + return nil; + } + clock:^NSTimeInterval { + return 0; + }]; + + XCTAssertNoThrow([tracker originFindingTheRoot]); + XCTAssertNil([tracker originFindingTheRoot]); +} + +@end diff --git a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactWindowTests.m b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactWindowTests.m index 07501a74..8210dcab 100644 --- a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactWindowTests.m +++ b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactWindowTests.m @@ -20,10 +20,10 @@ @interface BGSRNFakeModalHost : UIView @implementation BGSRNFakeModalHost @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 @@ -54,19 +54,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]; @@ -74,28 +94,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 { @@ -133,6 +142,17 @@ - (void)testTheSdkWalkedWindowsAreEmptyWithoutAKeyWindow { #pragma mark - The host by React tag (PR 48) +/// The window placement the Modal origins take, as the module passes +/// `BGSRNWindowRecordedOrigin`. The test runner's windows are on no screen, +/// where that has no place, so these tests place a window at its +/// `frame.origin`; `testAModalOriginAddsTheWindowsPlacement` checks the term. +- (NSValue *_Nullable (^)(UIWindow *))frameOrigin { + return ^NSValue *_Nullable(UIWindow *window) { + return [NSValue valueWithCGPoint:window.frame.origin]; + }; +} + + - (BOOL (^)(UIView *))isModalHost { return ^BOOL(UIView *view) { return [view isKindOfClass:BGSRNFakeModalHost.class]; @@ -251,15 +271,40 @@ - (void)testTheHostOriginIsThePresentedContentInItsWindowPlusTheWindowsOrigin { UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(10, 20, 390, 844)]; BGSRNFakeModalHost *host = [self hostTagged:42 inWindow:window presentedIn:window contentFrame:CGRectMake(0, 60, 390, 784)]; - XCTAssertEqualObjects(BGSRNModalHostOrigin(host), [NSValue valueWithCGPoint:CGPointMake(10, 80)]); + XCTAssertEqualObjects(BGSRNModalHostOrigin(host, [self frameOrigin]), [NSValue valueWithCGPoint:CGPointMake(10, 80)]); +} + +/// The window's term is the placement the module passes: the window's place +/// on its screen (`BGSRNWindowRecordedOrigin`), the main surface's convention, +/// not `frame.origin`, which is {0, 0} for a Stage Manager window or the +/// right-hand one side by side. +- (void)testAModalOriginAddsTheWindowsPlacement { + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(10, 20, 390, 844)]; + BGSRNFakeModalHost *host = [self hostTagged:42 inWindow:window presentedIn:window contentFrame:CGRectMake(0, 60, 390, 784)]; + NSValue *_Nullable (^onTheScreen)(UIWindow *) = ^NSValue *_Nullable(UIWindow *placed) { + return placed == window ? [NSValue valueWithCGPoint:CGPointMake(300, 40)] : nil; + }; + + XCTAssertEqualObjects(BGSRNModalHostOrigin(host, onTheScreen), [NSValue valueWithCGPoint:CGPointMake(300, 100)]); +} + +/// A window with no place (on no screen) gives the Modal no origin: its lane +/// keeps the origin it had, unknown for a new one, rather than a guess. +- (void)testAModalInAWindowWithNoPlacementHasNoOrigin { + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(10, 20, 390, 844)]; + BGSRNFakeModalHost *host = [self hostTagged:42 inWindow:window presentedIn:window contentFrame:CGRectMake(0, 60, 390, 784)]; + + XCTAssertNil(BGSRNModalHostOrigin(host, ^NSValue *_Nullable(UIWindow *placed) { + return nil; + })); } - (void)testAHostWhoseContentHasNoWindowHasNoOrigin { UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 390, 844)]; BGSRNFakeModalHost *host = [self hostTagged:42 inWindow:window presentedIn:nil contentFrame:CGRectMake(0, 60, 390, 784)]; - XCTAssertNil(BGSRNModalHostOrigin(host)); - XCTAssertNil(BGSRNModalHostOrigin([UIView new])); + XCTAssertNil(BGSRNModalHostOrigin(host, [self frameOrigin])); + XCTAssertNil(BGSRNModalHostOrigin([UIView new], [self frameOrigin])); } /// What a module's view registry answers: `host` for `tag`, nothing else. @@ -284,7 +329,7 @@ - (void)testTheOriginComesFromTheCurrentRuntimesHost { BGSRNSecureRectangles *store = [self storeWithLaneOn:42 claim:&claim]; XCTAssertTrue([store setHostResolver:[self registryWith:current calls:NULL] forSurface:42 runtime:claim]); - NSValue *origin = BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost]); + NSValue *origin = BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost], [self frameOrigin]); XCTAssertEqualObjects(origin, [NSValue valueWithCGPoint:CGPointMake(0, 60)]); } @@ -305,13 +350,13 @@ - (void)testTheLookupIsAskedUntilTheHostIsMountedThenTheHostIsHeld { forSurface:42 runtime:claim]; - XCTAssertNil(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost])); + XCTAssertNil(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost], [self frameOrigin])); XCTAssertEqual(calls, 1u); mounted = [self hostTagged:42 inWindow:window presentedIn:window contentFrame:CGRectMake(0, 60, 390, 784)]; - XCTAssertEqualObjects(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost]), + XCTAssertEqualObjects(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost], [self frameOrigin]), [NSValue valueWithCGPoint:CGPointMake(0, 60)]); - XCTAssertEqualObjects(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost]), + XCTAssertEqualObjects(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost], [self frameOrigin]), [NSValue valueWithCGPoint:CGPointMake(0, 60)]); XCTAssertEqual(calls, 2u); } @@ -330,7 +375,7 @@ - (void)testALookupAnswerThatIsNotAModalHostIsNotUsed { forSurface:42 runtime:claim]; - XCTAssertNil(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost])); + XCTAssertNil(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost], [self frameOrigin])); XCTAssertNil([store hostForSurface:42 accepting:^BOOL(id candidate) { return NO; }]); } @@ -342,7 +387,7 @@ - (void)testAfterAClaimTheOldHostsOriginIsNeverUsed { NSInteger oldClaim = 0; BGSRNSecureRectangles *store = [self storeWithLaneOn:42 claim:&oldClaim]; XCTAssertTrue([store setHostResolver:[self registryWith:old calls:NULL] forSurface:42 runtime:oldClaim]); - XCTAssertEqualObjects(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost]), + XCTAssertEqualObjects(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost], [self frameOrigin]), [NSValue valueWithCGPoint:CGPointMake(0, 300)]); const NSInteger current = [store claimRuntime]; @@ -353,14 +398,14 @@ - (void)testAfterAClaimTheOldHostsOriginIsNeverUsed { BGSRNFakeModalHost *fresh = [self hostTagged:42 inWindow:window presentedIn:window contentFrame:CGRectMake(0, 0, 390, 844)]; // Before the new module names its host: two matches, so no origin. - XCTAssertNil(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost])); + XCTAssertNil(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost], [self frameOrigin])); // The old module's late naming is stale. XCTAssertFalse([store setHostResolver:[self registryWith:old calls:NULL] forSurface:42 runtime:oldClaim]); XCTAssertFalse([store setHost:old forSurface:42 runtime:oldClaim]); - XCTAssertNil(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost])); + XCTAssertNil(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost], [self frameOrigin])); // The new module's lookup places the lane by its own host. XCTAssertTrue([store setHostResolver:[self registryWith:fresh calls:NULL] forSurface:42 runtime:current]); - XCTAssertEqualObjects(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost]), + XCTAssertEqualObjects(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost], [self frameOrigin]), [NSValue valueWithCGPoint:CGPointZero]); } @@ -374,8 +419,8 @@ - (void)testTwoWalkMatchesGiveNoOrigin { BGSRNSecureRectangles *store = [self storeWithLaneOn:42 claim:NULL]; XCTAssertNil(BGSRNUniqueTaggedView(@[ window, other ], 42, [self isModalHost], BGSRNModalHostSearchBudget)); - XCTAssertNil(BGSRNSecureSurfaceOrigin(store, 42, @[ window, other ], [self isModalHost])); - XCTAssertEqualObjects(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost]), + XCTAssertNil(BGSRNSecureSurfaceOrigin(store, 42, @[ window, other ], [self isModalHost], [self frameOrigin])); + XCTAssertEqualObjects(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost], [self frameOrigin]), [NSValue valueWithCGPoint:CGPointMake(0, 300)]); } @@ -385,7 +430,7 @@ - (void)testAWalkMatchThatIsNotPresentedGivesNoOrigin { [self hostTagged:42 inWindow:window presentedIn:nil contentFrame:CGRectMake(0, 60, 390, 784)]; BGSRNSecureRectangles *store = [self storeWithLaneOn:42 claim:NULL]; - XCTAssertNil(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost])); + XCTAssertNil(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost], [self frameOrigin])); } /// A held host that has gone is looked up again; the lookup not finding it @@ -408,7 +453,7 @@ - (void)testAGoneHostTheLookupCannotFindGivesNoOrigin { runtime:claim]; } - XCTAssertNil(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost])); + XCTAssertNil(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost], [self frameOrigin])); XCTAssertEqual(calls, 1u); } @@ -435,7 +480,7 @@ - (void)testAnOldHostAloneWhileTheNewHostIsNotMountedGivesNoOrigin { XCTAssertNotNil(BGSRNUniqueTaggedView(@[ window ], 42, [self isModalHost], BGSRNModalHostSearchBudget), @"the fixture's old host is the walk's one match"); - NSValue *origin = BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost]); + NSValue *origin = BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost], [self frameOrigin]); XCTAssertNil(origin); // What the pull then serves: the lane's origin is still unknown. @@ -457,8 +502,47 @@ - (void)testWithNoLookupNamedTheUniqueWalkPlacesTheLane { BGSRNSecureRectangles *store = [self storeWithLaneOn:42 claim:NULL]; XCTAssertFalse([store isHostNamedForSurface:42]); - XCTAssertEqualObjects(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost]), + XCTAssertEqualObjects(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost], [self frameOrigin]), [NSValue valueWithCGPoint:CGPointMake(0, 300)]); } +/// 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/BGSRNSecureRectanglesTests.m b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m index 018e7778..02f77d3a 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 diff --git a/packages/react-native/src/NativeBugsee.ts b/packages/react-native/src/NativeBugsee.ts index d1c0bde4..14f1b5af 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 12b91392..c113e3f5 100644 --- a/packages/react-native/src/secure/BugseeSecure.tsx +++ b/packages/react-native/src/secure/BugseeSecure.tsx @@ -31,7 +31,8 @@ export interface BugseeSecureProps extends ViewProps { * it is in. The rectangle is published under that surface's key (the Modal * host's tag, read off the fiber tree once per mount), so native translates * it by that surface's origin at pull time. No native call is made to find - * the surface. + * the surface. On iOS that origin includes the window's place on its screen + * (Stage Manager, Split View, iPhone Duo side by side), where the SDK draws. * * A plain function component on purpose (no `memo`, no `forwardRef`): the view * tree walk recognises it by identity. From 885bcf1d1f692b387ca6ddb51a5fc96f654e773f Mon Sep 17 00:00:00 2001 From: Denis Sheikherev Date: Tue, 6 Oct 2026 17:36:12 +0700 Subject: [PATCH 2/2] chore(ios): pin the iOS SDK to 7.0.0-beta4 7.0.0-beta4 is the release that composes every window of the app on the screen (bugsee-cocoa #170-#172, #186), which the previous commit places secure rectangles and the view tree for. - Core: native-versions.json, ios/Support/Package.swift and Package.resolved (revision e467f57c, the bugsee/spm tag). - Feedback: both exact SPM pins and the README. An app using both packages through SPM cannot resolve two different exact pins on bugsee/spm. - The design's Goals and the plan's Global Constraints name the live pin (docs-versions check). - e2e secure-component and secure-modal: from beta4 the iOS SDK writes video.aux version 2. A full-screen app's frame is the screen, in points, with no letterbox padding, so the width-fit mapping still holds; the check now asserts that instead of no video.aux at all. Co-Authored-By: Claude Opus 5.5 --- docs/design/2026-09-15-sdk-design.md | 2 +- .../plans/2026-09-16-implementation-plan.md | 2 +- examples/bare/e2e/secure-component.test.ts | 26 +++++++++++++------ examples/bare/e2e/secure-modal.test.ts | 14 +++++++++- native-versions.json | 2 +- packages/react-native-feedback/README.md | 2 +- .../react-native-feedback/ios/Package.swift | 4 +-- .../react-native/ios/Support/Package.resolved | 4 +-- .../react-native/ios/Support/Package.swift | 2 +- scripts/__tests__/native-versions.test.ts | 2 +- 10 files changed, 41 insertions(+), 19 deletions(-) diff --git a/docs/design/2026-09-15-sdk-design.md b/docs/design/2026-09-15-sdk-design.md index c9eeb510..a1ec7dbd 100644 --- a/docs/design/2026-09-15-sdk-design.md +++ b/docs/design/2026-09-15-sdk-design.md @@ -14,7 +14,7 @@ The single most important finding behind this design: **iOS 7.x and Android 7.x ## 2. Goals -- Ship a 7.x-native React Native SDK for the two current native lines: Android **7.3.0** (pinned transitionally as `7.3.0-SNAPSHOT` while the release is pending — see the plan's Phase 3 rulings) and iOS **7.0.0-beta3** (SPM only, iOS deployment target 15.0). +- Ship a 7.x-native React Native SDK for the two current native lines: Android **7.3.0** (pinned transitionally as `7.3.0-SNAPSHOT` while the release is pending — see the plan's Phase 3 rulings) and iOS **7.0.0-beta4** (SPM only, iOS deployment target 15.0). - Expose the capabilities 7.x added and 6.x never had: breadcrumbs, notification relay, APM, user identity, hang/HTTP-error/frustration/anomaly detection, SDK status, report handlers. - Make the wrapper's option surface **provably** consistent with the native SDKs, enforced in CI rather than by review. - Work on bare React Native and on Expo, including Expo apps that regenerate native projects with `prebuild`. diff --git a/docs/design/plans/2026-09-16-implementation-plan.md b/docs/design/plans/2026-09-16-implementation-plan.md index da3bd6ed..42de4e03 100644 --- a/docs/design/plans/2026-09-16-implementation-plan.md +++ b/docs/design/plans/2026-09-16-implementation-plan.md @@ -18,7 +18,7 @@ Every phase's requirements implicitly include this section. - React Native floor **0.81.0**, New Architecture required. No `oldarch` source set, no legacy bridge fallback. 0.81 still allows opting out of the New Architecture; that is a documented requirement on the consumer, not a second code path. Hard technical floor is 0.80 (`codegenConfig.ios.modulesProvider`). - Android SDK **7.3.0** (pinned transitionally as `7.3.0-SNAPSHOT` while the release is pending — see Phase 3's rulings), Gradle plugin **4.0.7**, pinned exactly. Neither older pin carries what this wrapper needs: 7.2.0 has no wrapper channel, report-contract methods, `BugseeReportHandlerThread` or `com.bugsee.option.$$WRAPPER` consumer, and 4.0.6 strips every extension's provider (workbook 1.4). The plugin marker resolves from **Maven Central**, not the Gradle Plugin Portal. -- iOS SDK **7.0.0-beta3** from `https://github.com/bugsee/spm`, requirement `exact`. SwiftPM will not admit a prerelease into a range. iOS deployment target **15.0**. +- iOS SDK **7.0.0-beta4** from `https://github.com/bugsee/spm`, requirement `exact`. SwiftPM will not admit a prerelease into a range. iOS deployment target **15.0**. - Package `@bugsee/react-native`. `toSwiftName` maps it to `ReactNative`, which is **reserved**, so `react-native.config.js` pins `spm: { name: 'BugseeReactNative' }` and the SPM product name must match exactly. - All native versions live in **one** file, `native-versions.json`, consumed by the podspec, `Package.swift` and the Gradle module. The SPM pin and podspec URL must never disagree. - Every SDK entry point is called on the **main thread** on iOS. `launchWithToken:` off-main logs *"Incorrect bugsee launch…"* and misbehaves. diff --git a/examples/bare/e2e/secure-component.test.ts b/examples/bare/e2e/secure-component.test.ts index 2f711b93..056bcfa6 100644 --- a/examples/bare/e2e/secure-component.test.ts +++ b/examples/bare/e2e/secure-component.test.ts @@ -37,9 +37,9 @@ * rectangles, in points (the component's twin, and the witness), mapped onto * a screenshot at `screenshot.width / Dimensions.screen.width` -- the * simulator's window is full-screen, so window points are screen points. The - * video maps the same way (media.ts `widthFitRegion`: no letterbox, and no - * `video.aux`). iOS logs no served buffer, so the served-rectangle check is - * Android-only, and `E2E_EDGE_TO_EDGE` is not read. + * video maps the same way (media.ts `widthFitRegion`: no letterbox, which the + * SDK's `video.aux` version 2 confirms). iOS logs no served buffer, so the + * served-rectangle check is Android-only, and `E2E_EDGE_TO_EDGE` is not read. */ import { type PulledBundle, airplane, captureEvents, removePulledBundles } from './bundles'; import { @@ -246,12 +246,22 @@ describeDevice(` on ${TARGET_NAME}`, () => { const size = await imageSize(video); let region: (rect: Rect) => { x: number; y: number; w: number; h: number }; if (ON_IOS) { - // iOS does not letterbox and writes no video.aux to check a mapping - // against: it scales by width from the top-left, as its screenshot - // does (media.ts widthFitRegion). The witness and control regions - // below are what prove the crops land where they should. + // iOS does not letterbox: it scales by width from the top-left, as its + // screenshot does (media.ts widthFitRegion). Since 7.0.0-beta4 it + // writes video.aux version 2 (the frame in points, padding only where + // the frame does not fill the video), and a full-screen app's frame is + // the screen, unpadded. The witness and control regions below are what + // prove the crops land where they should. report(`${String(bundle.request.summary)}: video size`, { size, screen: display, scale: size.width / display.width }); - expect(captureEvents(bundle, 'video.aux')).toEqual([]); + const aux = captureEvents(bundle, 'video.aux')[0] as + | { frameW?: number; frameH?: number; paddingH?: number; paddingV?: number } + | undefined; + report(`${String(bundle.request.summary)}: video frame`, { size, screen: display, aux }); + expect(aux).toBeDefined(); + expect(Math.abs(aux!.frameW! - display.width)).toBeLessThanOrEqual(0.5); + expect(Math.abs(aux!.frameH! - display.height)).toBeLessThanOrEqual(0.5); + expect(aux!.paddingH ?? 0).toBeLessThanOrEqual(1); + expect(aux!.paddingV ?? 0).toBeLessThanOrEqual(1); region = rect => widthFitRegion(rect, display, size, VIDEO_INSET); } else { const box = letterbox(display, size); diff --git a/examples/bare/e2e/secure-modal.test.ts b/examples/bare/e2e/secure-modal.test.ts index 7f5c2cbb..918907d7 100644 --- a/examples/bare/e2e/secure-modal.test.ts +++ b/examples/bare/e2e/secure-modal.test.ts @@ -340,7 +340,19 @@ for (const scenario of SCENARIOS) { let region: (rect: Rect) => { x: number; y: number; w: number; h: number }; if (ON_IOS) { // The iOS SDK scales by width from the top-left, as its screenshot. - expect(captureEvents(bundle, 'video.aux')).toEqual([]); + // Since 7.0.0-beta4 it writes video.aux version 2: the frame in + // points, padding only where the frame does not fill the video. A + // full-screen app's frame is the screen and fills it, so the width + // fit holds, and the sidecar must say so. + const aux = captureEvents(bundle, 'video.aux')[0] as + | { frameW?: number; frameH?: number; paddingH?: number; paddingV?: number } + | undefined; + report('video frame', { size, screen: display, aux }); + expect(aux).toBeDefined(); + expect(Math.abs(aux!.frameW! - display.width)).toBeLessThanOrEqual(0.5); + expect(Math.abs(aux!.frameH! - display.height)).toBeLessThanOrEqual(0.5); + expect(aux!.paddingH ?? 0).toBeLessThanOrEqual(1); + expect(aux!.paddingV ?? 0).toBeLessThanOrEqual(1); region = rect => widthFitRegion(rect, display, size, VIDEO_INSET); } else { const box = letterbox(display, size); diff --git a/native-versions.json b/native-versions.json index 1e323d93..d0c59b06 100644 --- a/native-versions.json +++ b/native-versions.json @@ -4,7 +4,7 @@ "gradlePlugin": "4.0.7" }, "ios": { - "sdk": "7.0.0-beta3", + "sdk": "7.0.0-beta4", "spmUrl": "https://github.com/bugsee/spm" } } diff --git a/packages/react-native-feedback/README.md b/packages/react-native-feedback/README.md index 30ece1c1..343f18ec 100644 --- a/packages/react-native-feedback/README.md +++ b/packages/react-native-feedback/README.md @@ -16,7 +16,7 @@ yarn add @bugsee/react-native @bugsee/react-native-feedback No Expo config plugin. Autolinking adds `bugsee-android-feedback` on Android. On iOS the podspec vendors the `bugsee/feedback-spm` sources for the same -`7.0.0-beta3` pin as the core SDK, and React Native 0.87 resolves that +`7.0.0-beta4` pin as the core SDK, and React Native 0.87 resolves that package through Swift Package Manager. There is no `spm_dependency`: that helper does not embed the framework. diff --git a/packages/react-native-feedback/ios/Package.swift b/packages/react-native-feedback/ios/Package.swift index de2aeddf..b2bb24c1 100644 --- a/packages/react-native-feedback/ios/Package.swift +++ b/packages/react-native-feedback/ios/Package.swift @@ -22,8 +22,8 @@ let package = Package( dependencies: [ .package(name: "ReactNative", path: "../../../../xcframeworks"), .package(name: "React-GeneratedCode", path: "../../../ios"), - .package(url: "https://github.com/bugsee/spm", exact: "7.0.0-beta3"), - .package(url: "https://github.com/bugsee/feedback-spm", exact: "7.0.0-beta3"), + .package(url: "https://github.com/bugsee/spm", exact: "7.0.0-beta4"), + .package(url: "https://github.com/bugsee/feedback-spm", exact: "7.0.0-beta4"), ], targets: [ .target( diff --git a/packages/react-native/ios/Support/Package.resolved b/packages/react-native/ios/Support/Package.resolved index 91c58249..f7742322 100644 --- a/packages/react-native/ios/Support/Package.resolved +++ b/packages/react-native/ios/Support/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/bugsee/spm", "state" : { - "revision" : "6e0895b42d420646684e46129a26565df9e5c8ea", - "version" : "7.0.0-beta3" + "revision" : "e467f57cba4f68dbb99db6236675af002661eb5f", + "version" : "7.0.0-beta4" } } ], diff --git a/packages/react-native/ios/Support/Package.swift b/packages/react-native/ios/Support/Package.swift index 580f3445..fdf9a13d 100644 --- a/packages/react-native/ios/Support/Package.swift +++ b/packages/react-native/ios/Support/Package.swift @@ -29,7 +29,7 @@ let package = Package( ], dependencies: [ // Exact, not a range: SwiftPM will not admit a prerelease into one. - .package(url: "https://github.com/bugsee/spm", exact: "7.0.0-beta3") + .package(url: "https://github.com/bugsee/spm", exact: "7.0.0-beta4") ], targets: [ .target( diff --git a/scripts/__tests__/native-versions.test.ts b/scripts/__tests__/native-versions.test.ts index 70ad9a52..56164be8 100644 --- a/scripts/__tests__/native-versions.test.ts +++ b/scripts/__tests__/native-versions.test.ts @@ -12,7 +12,7 @@ describe('readNativeVersions', () => { expect(v.android.gradlePlugin).toBe('4.0.7'); // A released pin carries no snapshot provenance. expect(v.android).not.toHaveProperty('snapshotCommit'); - expect(v.ios.sdk).toBe('7.0.0-beta3'); + expect(v.ios.sdk).toBe('7.0.0-beta4'); expect(v.ios.spmUrl).toBe('https://github.com/bugsee/spm'); });