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/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/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/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. 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'); });