Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/design/2026-09-15-sdk-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion docs/design/plans/2026-09-16-implementation-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 18 additions & 8 deletions examples/bare/e2e/secure-component.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -246,12 +246,22 @@ describeDevice(`<BugseeSecure> 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);
Expand Down
14 changes: 13 additions & 1 deletion examples/bare/e2e/secure-modal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion native-versions.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
2 changes: 1 addition & 1 deletion packages/react-native-feedback/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions packages/react-native-feedback/ios/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
101 changes: 80 additions & 21 deletions packages/react-native/ios/BugseeModule.mm
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
#import <BugseeRNSupport/BGSRNWrapperChannelHolder.h>
#import <BugseeRNSupport/BGSRNStatusMapper.h>
#import <BugseeRNSupport/BGSRNSecureRectangles.h>
#import <BugseeRNSupport/BGSRNReactRootOriginTracker.h>
#import <BugseeRNSupport/BGSRNEventBus.h>
#import <BugseeRNSupport/BGSRNTokens.h>
#import <BugseeRNSupport/BGSRNReportHandlerBridge.h>
Expand All @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -234,32 +238,43 @@ 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");
});
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;
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <Modal>'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.
Expand Down
4 changes: 2 additions & 2 deletions packages/react-native/ios/Support/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/react-native/ios/Support/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading