Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 76 additions & 22 deletions packages/react-native/ios/BugseeModule.mm
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
#import <BugseeRNSupport/BGSRNWrapperChannelHolder.h>
#import <BugseeRNSupport/BGSRNStatusMapper.h>
#import <BugseeRNSupport/BGSRNSecureRectangles.h>
#import <BugseeRNSupport/BGSRNSecureRectanglePulls.h>
#import <BugseeRNSupport/BGSRNReactRootOriginTracker.h>
#import <BugseeRNSupport/BGSRNEventBus.h>
#import <BugseeRNSupport/BGSRNTokens.h>
#import <BugseeRNSupport/BGSRNReportHandlerBridge.h>
Expand All @@ -38,6 +40,8 @@
#import "BGSRNWrapperChannelHolder.h"
#import "BGSRNStatusMapper.h"
#import "BGSRNSecureRectangles.h"
#import "BGSRNSecureRectanglePulls.h"
#import "BGSRNReactRootOriginTracker.h"
#import "BGSRNEventBus.h"
#import "BGSRNTokens.h"
#import "BGSRNReportHandlerBridge.h"
Expand All @@ -55,6 +59,9 @@
#import "BGSRNCreatedReportOps.h"
#endif

/// Defined below, beside the React root lookup it closes over.
static BGSRNReactRootOriginTracker *BGSRNSecureOriginTracker(void);

/// The conformance lives here rather than in the Support package so that the
/// package stays buildable and testable without the SDK's headers. BGSRNWrapper
/// already declares every property the protocol requires; this states that it
Expand Down Expand Up @@ -87,15 +94,19 @@ - (void)onLifecycleEvent:(NSString *)eventType data:(id)data {
}

/// The packed buffer the SDK expects: `[version, count, l,t,r,b, ...]` as
/// little-endian int32.
/// little-endian int32, every rectangle moved from the React root's window to
/// the screen.
///
/// Read from the process-wide store rather than from this instance. The SDK
/// pulls 2-3 times a second on the MAIN thread, and the wrapper it pulls
/// pulls on the MAIN thread once per captured frame, and the wrapper it pulls
/// through is replaced when `setWrapperInfo` runs — regions the app marked
/// secret must survive that swap. See `BGSRNSecureRectangles` for the version
/// contract, which is what makes the SDK notice a change at all.
/// contract, which is what makes the SDK notice a change at all. The pull
/// also re-reads the window's place on the screen
/// (`BGSRNSecureRectanglePulls`): a window can move with nothing published.
- (NSData *)secureRectanglesForDisplay:(NSInteger)display {
return [BGSRNSecureRectangles.shared snapshotForDisplay:display];
(void)BGSRNSecureOriginTracker(); // installs the pulls' refresher on first use
return [BGSRNSecureRectanglePulls.shared pullForDisplay:display];
}

/// Through the data request bridge, for the same reason lifecycle events go
Expand Down Expand Up @@ -195,34 +206,71 @@ static void BGSRNSetWrapper(id<BugseeWrapper> _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;
dispatch_once(&once, ^{
surfaceHostingView = NSClassFromString(@"RCTSurfaceHostingView");
legacyRootView = NSClassFromString(@"RCTRootView");
});
return (surfaceHostingView != Nil && [view isKindOfClass:surfaceHostingView]) ||
(legacyRootView != Nil && [view isKindOfClass:legacyRootView]);
}

static NSValue *_Nullable BGSRNReactOrigin(void) {
if (!NSThread.isMainThread) {
return nil;
}
UIWindow *keyWindow = BGSRNSdkKeyWindow();
return BGSRNReactRootOrigin(keyWindow, BGSRNSdkWalkedWindows(keyWindow), ^BOOL(UIView *view) {
return (surfaceHostingView != Nil && [view isKindOfClass:surfaceHostingView]) ||
(legacyRootView != Nil && [view isKindOfClass:legacyRootView]);
return BGSRNIsReactRoot(view);
});
}

/// Keeps the secure rectangles on the window JS measures them in: the iOS
/// peer of Android's `ReactRootOriginTracker` (see
/// `BGSRNReactRootOriginTracker`). Process-wide, like the store and the pulls
/// it feeds: the window lookup reads only UIKit, nothing of one module.
/// Created by the first pull or publish, which also installs it as the pulls'
/// refresher.
static BGSRNReactRootOriginTracker *BGSRNSecureOriginTracker(void) {
static BGSRNReactRootOriginTracker *tracker = nil;
static dispatch_once_t once;
dispatch_once(&once, ^{
tracker = [[BGSRNReactRootOriginTracker alloc]
initWithStore:BGSRNSecureRectangles.shared
findRoot:^UIView *_Nullable {
UIWindow *keyWindow = BGSRNSdkKeyWindow();
return BGSRNReactRootView(keyWindow,
BGSRNSdkWalkedWindows(keyWindow),
^BOOL(UIView *view) {
return BGSRNIsReactRoot(view);
},
BGSRNReactRootSearchBudget);
}
readOrigin:^NSValue *_Nullable(UIWindow *window) {
return BGSRNWindowRecordedOrigin(window);
}];
BGSRNReactRootOriginTracker *installed = tracker;
BGSRNSecureRectanglePulls.shared.refresher = ^{
[installed refresh];
};
});
return tracker;
}

static NSString *const kHandleDeadCode = @"E_REPORT_HANDLE_DEAD";
static NSString *const kCreateBusyCode = @"E_REPORT_CREATE_BUSY";

Expand Down Expand Up @@ -416,18 +464,24 @@ - (void)setSecureRectangles:(double)display
// is. Rounding rather than truncating: the JS side has already rounded each
// edge outwards, and truncating would pull an edge back inside the region it
// was widened to cover.
int32_t *flat = count > 0 ? (int32_t *)malloc(count * sizeof(int32_t)) : NULL;
if (count > 0 && flat == NULL) {
NSMutableData *flat = [NSMutableData dataWithLength:count * sizeof(int32_t)];
if (flat == nil) {
return;
}
int32_t *values = (int32_t *)flat.mutableBytes;
for (NSUInteger i = 0; i < count; i++) {
flat[i] = (int32_t)llround([coordinates[i] doubleValue]);
values[i] = (int32_t)llround([coordinates[i] doubleValue]);
}

[BGSRNSecureRectangles.shared setCoordinates:flat
count:count
forDisplay:(NSInteger)display];
free(flat);
// JS measured in the React root's window. The tracker reads where that
// window sits on the screen and only then writes the rectangles, both on
// main, where the SDK pulls: no pull ever serves them at an origin not yet
// read (see -[BGSRNReactRootOriginTracker publishCoordinates:forDisplay:]).
BGSRNReactRootOriginTracker *tracker = BGSRNSecureOriginTracker();
const NSInteger target = (NSInteger)display;
BGSRNRunOnMain(^{
[tracker publishCoordinates:flat forDisplay:target];
});
Comment thread
cursor[bot] marked this conversation as resolved.
}

#pragma mark - Blackout and view-hierarchy capture (design doc §4.1)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#import "BGSRNReactRootOriginTracker.h"

#import "BGSRNSecureRectangles.h"

@implementation BGSRNReactRootOriginTracker {
BGSRNSecureRectangles *_store;
UIView *_Nullable (^_findRoot)(void);
NSValue *_Nullable (^_readOrigin)(UIWindow *window);
/// The React root found last. Weak: a reload replaces it, and the tracker
/// must not keep the old one alive.
__weak UIView *_root;
}

- (instancetype)initWithStore:(BGSRNSecureRectangles *)store
findRoot:(UIView *_Nullable (^)(void))findRoot
readOrigin:(NSValue *_Nullable (^)(UIWindow *window))readOrigin {
self = [super init];
if (self) {
_store = store;
_findRoot = [findRoot copy];
_readOrigin = [readOrigin copy];
}
return self;
}

- (void)refreshFindingTheRoot {
[self refreshFinding:YES];
}

- (void)refresh {
[self refreshFinding:NO];
}

- (BOOL)publishCoordinates:(NSData *)coordinates forDisplay:(NSInteger)display {
[self refreshFindingTheRoot];
return [_store setCoordinates:(const int32_t *)coordinates.bytes
count:coordinates.length / sizeof(int32_t)
forDisplay:display];
}

- (void)refreshFinding:(BOOL)find {
@try {
UIView *root = _root;
if (root.window == nil && find) {
root = _findRoot();
_root = root;
}
UIWindow *window = [root isKindOfClass:UIWindow.class] ? (UIWindow *)root : root.window;
if (window == nil) {
return;
}
NSValue *origin = _readOrigin(window);
if (origin == nil) {
return;
}
[_store setOrigin:origin.CGPointValue forDisplay:0];
} @catch (NSException *exception) {
NSLog(@"[Bugsee] secure rectangles: could not read the React root's place on the screen: %@",
exception);
}
}

@end
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,39 @@

const NSUInteger BGSRNReactRootSearchBudget = 2000;

/// Whether the SDK composes the app's windows on the screen: iOS, and not an
/// iPhone or iPad app running on a Mac (`+[BGSTrackerApplication
/// capturesAppScreen]`).
static BOOL BGSRNSdkComposesScreen(void) {
#if TARGET_OS_MACCATALYST
return NO;
#else
return !NSProcessInfo.processInfo.isiOSAppOnMac;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 High — Still open after 99012d7 / 282233b. This helper is only “not Catalyst / not iOS-on-Mac”, so on a real iPhone or iPad BGSRNWindowRecordedOrigin always adds the window’s place on the screen (and BGSRNSdkWalkedWindows takes the multi-scene walk).

The declared pin is still 7.0.0-beta3 (native-versions.json, ios/Support/Package.swift, Package.resolved). Beta3 records the key window’s scene and draws secure rectangles and native nodes in that scene’s points. frame.origin is {0,0} for a Stage Manager / Split View window; the correct origin for beta3 is therefore {0,0}. After this change the wrapper serves screen points (e.g. {359, 64} from the PR’s iPad run). Masks and the React tree sit that far down and right of the views. Secret UI is recorded in the clear. Task 6.6 review I1 switched to frame.origin for this contract (BGSCaptureViewHierarchyEngine adding frame.origin on SDK 0d9c9d0a3).

The PR body already says ship with cocoa #170–#172. That SDK is not what this branch resolves. Comments and the Stage Manager check (run against a locally built cocoa SDK) do not change what a consumer of main gets.

Scenario: Consumer builds this wrapper against the declared 7.0.0-beta3 pin. iPad Stage Manager or the right-hand Split View window. <BugseeSecure> and vh both go through BGSRNWindowRecordedOrigin. iPhone is fine ({0,0} == {0,0}); simulator e2e is also fine because the scene fills the screen.

Fix: Ship this origin only with the compositor SDK (bump the pin in this PR), or detect that SDK and keep window.frame.origin on beta3. Do not merge onto a main that still resolves 7.0.0-beta3.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, this is the gate the PR body states: the screen origin is right only with an SDK that composes the app's windows on the screen (bugsee-cocoa #170–#172). This PR does not merge while the pin is 7.0.0-beta3; the pin moves to the release with #170–#172 in the same change. Detecting the SDK at runtime would be dead code: the wrapper vendors exactly the pinned SDK. Converted to draft until then.

#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;
Expand Down Expand Up @@ -49,54 +77,98 @@
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<UIWindow *> *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<UIWindow *> *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 ?: @[];
}

/// 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<UIView *> *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<UIWindow *> *windows,
BOOL (^isReactRoot)(UIView *),
NSUInteger budget) {
if (keyWindow != nil && HostsReactRoot(keyWindow, isReactRoot, budget)) {
return keyWindow;
UIView *BGSRNReactRootView(UIWindow *keyWindow,
NSArray<UIWindow *> *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<UIWindow *> *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<UIWindow *> *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<UICoordinateSpace> 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];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 High — This path assumes the iOS SDK composites every window onto the screen. That is not 7.0.0-beta3, which is still what native-versions.json, ios/Support/Package.swift, and Package.resolved pin.

BGSRNSdkComposesScreen() is only “not Catalyst / not iOS-on-Mac”. On a real iPhone or iPad it always returns YES, so this function (and the vh origin that calls it) always adds the window’s place on the screen.

Impact: Beta3 records the key window’s scene and draws secure rectangles and native nodes in that scene’s points. frame.origin is {0,0} for a Stage Manager / Split View window; the correct origin for beta3 is therefore {0,0}. After this change the wrapper serves screen points (e.g. {359, 64} from the PR’s iPad run). Masks and the React tree sit that far down and right of the views. Secret UI is recorded in the clear. Task 6.6 review I1 switched to frame.origin for this contract (BGSCaptureViewHierarchyEngine adding frame.origin on SDK 0d9c9d0a3).

Scenario: Consumer builds this wrapper against the declared 7.0.0-beta3 pin (CocoaPods / SPM). iPad Stage Manager or the right-hand Split View window. <BugseeSecure> and vh both go through BGSRNWindowRecordedOrigin. iPhone is fine ({0,0} == {0,0}); simulator e2e is also fine because the scene fills the screen. The PR’s own Stage Manager check used a locally built cocoa SDK, not the pin.

Fix: Ship this origin only with the compositor SDK (bump the pin in this PR), or detect that SDK and keep window.frame.origin on beta3. Do not merge a main that still resolves 7.0.0-beta3.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, this is the gate the PR body states: the screen origin is right only with an SDK that composes the app's windows on the screen (bugsee-cocoa #170–#172). This PR does not merge while the pin is 7.0.0-beta3; the pin moves to the release with #170–#172 in the same change. Detecting the SDK at runtime would be dead code: the wrapper vendors exactly the pinned SDK. Converted to draft until then.

}
Loading
Loading