From 35d6794234fcc5d84e0c74593eb1044367fd50fc Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Sun, 4 Oct 2026 22:31:47 +0500 Subject: [PATCH 01/19] fix(android): serve each React surface's secure rects by its own origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabric measureInWindow is relative to the measured node's nearest RootNodeKind ancestor, so a rectangle inside a is relative to the dialog's DialogRootViewGroup, not the activity root. SecureRectangleStore now keeps one lane per surface (raw rects + that root's locationOnScreen - viewportOffset) and serves them merged in surface-key order; the legacy single-surface set/setOrigin keep meaning the main surface. ReactRootOriginTracker watches extra dialog roots, refreshes their origins with the activity root, mirrors the activity origin onto MAIN_SURFACE for manual writers, and keeps and releases each watched root's layout-listener token. Ported from feat/phase-6-modal (9ec12a2, e6e886f) onto main. TDD: SecureRectangleStoreTest's two-surface cases failed against a surface-ignoring stub (expected 2 rectangles, was 1); tracker tests did not compile without RootHandle.surfaceKey / watchNow. Mutation: publishing a watched dialog root with the activity root's origin fails aWatchedSurfacesOriginMovesOnlyItsRectangles (expected 140, was 100); reverted. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --- .../reactnative/ReactRootOriginTracker.java | 219 ++++++++++++++---- .../reactnative/SecureRectangleStore.java | 149 ++++++++++-- .../ReactRootOriginTrackerTest.java | 91 ++++++++ .../reactnative/SecureRectangleStoreTest.java | 54 +++++ 4 files changed, 446 insertions(+), 67 deletions(-) diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/ReactRootOriginTracker.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/ReactRootOriginTracker.java index b7a3d44d..5b97f053 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/ReactRootOriginTracker.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/ReactRootOriginTracker.java @@ -19,26 +19,36 @@ import com.facebook.react.uimanager.RootViewUtil; import java.lang.ref.WeakReference; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Iterator; +import java.util.List; /** - * Keeps {@link SecureRectangleStore}'s display origin in step with the React - * root view, so rectangles JS measured with {@code measureInWindow} are served - * to the SDK in display pixels. + * Keeps {@link SecureRectangleStore}'s per-surface display origins in step + * with each React root view, so rectangles JS measured with {@code + * measureInWindow} are served to the SDK in display pixels. * - *

The origin is the root's {@code getLocationOnScreen} less React Native's - * own viewport offset for it ({@link RootViewUtil#getViewportOffset}, the - * formula {@code ReactSurfaceView} feeds Fabric's layout constraints, which is - * what {@code measureInWindow} adds to a view's position in its root). With - * edge-to-edge on and a full-screen window that is (0, 0); with it off, it is - * the status-bar/cutout inset; in split-screen or freeform it is also the - * window's own position on the display. + *

The origin of a surface is that root's {@code getLocationOnScreen} less + * React Native's own viewport offset for it ({@link + * RootViewUtil#getViewportOffset}, the formula {@code ReactSurfaceView} feeds + * Fabric's layout constraints, which is what {@code measureInWindow} adds to + * a view's position in its root). With edge-to-edge on and a full-screen + * window that is (0, 0); with it off, it is the status-bar/cutout inset; in + * split-screen or freeform it is also the window's own position on the + * display. * - *

Re-read on every global layout of the root (rotation, insets arriving, - * a window resize), on host resume, on every JS publish, and at most every - * {@link SecureRectanglePulls#ORIGIN_REFRESH_MIN_INTERVAL_MS} while the SDK - * pulls (a window can move without a relayout). All view access is on the UI - * thread; the store is thread-safe. + *

Fabric {@code measureInWindow} stops at the nearest {@code RootNodeKind} + * ancestor ({@code ModalHostViewShadowNode} sets that trait), so a rectangle + * inside a {@code } is relative to the dialog's {@code + * DialogRootViewGroup}, not the activity root. Each surface therefore has its + * own origin; a single {@code setOrigin} for the display cannot serve both. + * + *

Re-read on every global layout of a watched root (rotation, insets + * arriving, a window resize), on host resume, on every JS publish, and at + * most every {@link SecureRectanglePulls#ORIGIN_REFRESH_MIN_INTERVAL_MS} + * while the SDK pulls (a window can move without a relayout). All view access + * is on the UI thread; the store is thread-safe. */ final class ReactRootOriginTracker implements LifecycleEventListener { @@ -48,10 +58,17 @@ final class ReactRootOriginTracker implements LifecycleEventListener { private final RootFinder rootFinder; private final SecureRectangleStore store; - /** UI thread only. */ + /** UI thread only: the activity React root, when found. */ @Nullable private RootHandle root; + /** + * Extra surfaces (a {@code }'s {@code DialogRootViewGroup}) + * registered via {@link #watch}, each paired with the layout-listener + * token from {@link RootHandle#addOnGlobalLayoutListener}. UI thread only. + */ + private final List watched = new ArrayList<>(); + /** * The layout-listener registration for {@link #root}, if any, released * through this same token later. Which observer it is removed from is @@ -63,6 +80,18 @@ final class ReactRootOriginTracker implements LifecycleEventListener { @Nullable private LayoutListenerToken layoutToken; + /** A watched surface and the layout token that must be released with it. */ + private static final class WatchedRoot { + final RootHandle handle; + final LayoutListenerToken token; + + WatchedRoot(@NonNull final RootHandle handle, + @NonNull final LayoutListenerToken token) { + this.handle = handle; + this.token = token; + } + } + /** * A copy of the {@code {x, y}} last passed to {@link * SecureRectangleStore#setOrigin}, for {@link #currentOrigin()} (Task @@ -146,44 +175,98 @@ void refresh() { try { RootHandle current = root; if (current == null || !current.isAttachedToWindow()) { - detach(); + detachPrimary(); current = rootFinder.findCurrentRoot(); - if (current == null) { - return; + if (current != null) { + layoutToken = current.addOnGlobalLayoutListener(this::refresh); + root = current; } - layoutToken = current.addOnGlobalLayoutListener(this::refresh); - root = current; } - // Resolved once, atomically: current holds the root's view only - // weakly, and reading location/viewport/display as separate - // calls could see the view collected partway through, mixing a - // real value read before that with a made-up default (0,0) / - // DEFAULT_DISPLAY read after it -- publishing a wrong origin is - // a privacy defect, not a cosmetic one, so a gone-mid-refresh - // view aborts the whole read instead: nothing new is published - // and the previous origin stands. - final OriginSnapshot snapshot = current.resolveOrigin(); - if (snapshot == null) { - return; - } - final int[] origin = - SecureRectangleStore.displayOrigin(snapshot.onScreen, snapshot.viewport.x, snapshot.viewport.y); - store.setOrigin(snapshot.displayId, origin[0], origin[1]); - lastOrigin = origin; - if (Log.isLoggable(TAG, Log.DEBUG)) { - Log.d(TAG, "secure origin display=" + snapshot.displayId - + " onScreen=" + snapshot.onScreen[0] + "," + snapshot.onScreen[1] - + " viewport=" + snapshot.viewport.x + "," + snapshot.viewport.y - + " origin=" + origin[0] + "," + origin[1] - + " served=" + Arrays.toString(store.snapshot(snapshot.displayId))); + if (current != null) { + publishOrigin(current, true); } + refreshWatched(); } catch (Throwable t) { Log.w(TAG, "secure rectangles: could not read the React root's display origin: " + t.getClass().getName()); } } + /** + * Tracks an additional React root (a {@code }'s dialog root) so + * its display origin is refreshed with the activity root. Safe from any + * thread: posts to the UI thread. + */ + void watch(@NonNull final RootHandle handle) { + UiThreadUtil.runOnUiThread(() -> watchNow(handle)); + } + + /** Package-visible for the same reason as {@link #refresh()}. */ + @UiThread + void watchNow(@NonNull final RootHandle handle) { + if (disposed) { + return; + } + for (final WatchedRoot existing : watched) { + if (existing.handle.surfaceKey() == handle.surfaceKey()) { + return; + } + } + final LayoutListenerToken token = handle.addOnGlobalLayoutListener(this::refresh); + watched.add(new WatchedRoot(handle, token)); + publishOrigin(handle, false); + } + + @UiThread + private void refreshWatched() { + final Iterator it = watched.iterator(); + while (it.hasNext()) { + final WatchedRoot entry = it.next(); + if (!entry.handle.isAttachedToWindow()) { + it.remove(); + entry.token.release(); + continue; + } + publishOrigin(entry.handle, false); + } + } + + @UiThread + private void publishOrigin(@NonNull final RootHandle handle, final boolean primary) { + // Resolved once, atomically: the handle holds the root's view only + // weakly, and reading location/viewport/display as separate calls + // could see the view collected partway through, mixing a real value + // read before that with a made-up default (0,0) / DEFAULT_DISPLAY + // read after it -- publishing a wrong origin is a privacy defect, + // not a cosmetic one, so a gone-mid-refresh view aborts the whole + // read instead: nothing new is published and the previous origin + // stands. + final OriginSnapshot snapshot = handle.resolveOrigin(); + if (snapshot == null) { + return; + } + final int[] origin = + SecureRectangleStore.displayOrigin(snapshot.onScreen, snapshot.viewport.x, snapshot.viewport.y); + store.setOrigin(snapshot.displayId, handle.surfaceKey(), origin[0], origin[1]); + // Manual setSecureRectangles and legacy callers publish on + // MAIN_SURFACE; mirror the activity root there so they keep working. + if (primary && handle.surfaceKey() != SecureRectangleStore.MAIN_SURFACE) { + store.setOrigin(snapshot.displayId, SecureRectangleStore.MAIN_SURFACE, origin[0], origin[1]); + } + if (primary) { + lastOrigin = origin; + } + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "secure origin display=" + snapshot.displayId + + " surface=" + handle.surfaceKey() + + " onScreen=" + snapshot.onScreen[0] + "," + snapshot.onScreen[1] + + " viewport=" + snapshot.viewport.x + "," + snapshot.viewport.y + + " origin=" + origin[0] + "," + origin[1] + + " served=" + Arrays.toString(store.snapshot(snapshot.displayId))); + } + } + /** * The React root's current display origin, for {@link DataRequestBridge} * (Task 6.5): the {@code vh} data request is answered on the same thread @@ -205,6 +288,15 @@ int[] currentOrigin() { /** Package-visible for the same reason as {@link #refresh()}. */ @UiThread void detach() { + detachPrimary(); + for (final WatchedRoot entry : watched) { + entry.token.release(); + } + watched.clear(); + } + + @UiThread + private void detachPrimary() { root = null; final LayoutListenerToken token = layoutToken; layoutToken = null; @@ -248,6 +340,12 @@ interface RootFinder { interface RootHandle { boolean isAttachedToWindow(); + /** + * Opaque key for {@link SecureRectangleStore}'s per-surface origin. + * The activity React root and a dialog root must not share one. + */ + int surfaceKey(); + /** * The root's current on-screen location, viewport offset and display * id, resolved as one atomic snapshot -- or {@code null} if the @@ -349,9 +447,20 @@ private static View firstRootView(@NonNull final View view) { */ private static final class ViewRootHandle implements RootHandle { private final WeakReference view; + private final int surfaceKey; ViewRootHandle(final View view) { this.view = new WeakReference<>(view); + this.surfaceKey = surfaceKeyOf(view); + } + + /** + * React tags for RootViews are positive; {@link View#NO_ID} falls + * back to identity so two untagged roots still stay apart. + */ + static int surfaceKeyOf(@NonNull final View view) { + final int id = view.getId(); + return id != View.NO_ID ? id : System.identityHashCode(view); } @Nullable @@ -365,6 +474,11 @@ public boolean isAttachedToWindow() { return v != null && v.isAttachedToWindow(); } + @Override + public int surfaceKey() { + return surfaceKey; + } + @Nullable @Override public OriginSnapshot resolveOrigin() { @@ -390,6 +504,25 @@ public LayoutListenerToken addOnGlobalLayoutListener(final Runnable onLayout) { } } + /** + * Builds a {@link RootHandle} for an already-resolved React root view + * (the activity root or a dialog's {@code DialogRootViewGroup}) and + * starts watching it. Package-visible so {@link BugseeModule} can + * register a surface when JS resolves {@code secureSurfaceKey}. + */ + @Nullable + static RootHandle handleFor(@Nullable final View rootView) { + return rootView == null ? null : new ViewRootHandle(rootView); + } + + /** + * The surface key {@link SecureRectangleStore} and JS share for {@code + * rootView}. Package-visible for {@link BugseeModule#secureSurfaceKey}. + */ + static int surfaceKeyOf(@NonNull final View rootView) { + return ViewRootHandle.surfaceKeyOf(rootView); + } + /** * Whether {@link ViewTreeObserverToken}'s listener should replace its * saved observer with the view's current one: only a cleared or dead diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/SecureRectangleStore.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/SecureRectangleStore.java index f9e06db0..114fa059 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/SecureRectangleStore.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/SecureRectangleStore.java @@ -7,6 +7,7 @@ import java.util.Arrays; import java.util.Map; +import java.util.TreeMap; import java.util.concurrent.ConcurrentHashMap; /** @@ -30,6 +31,18 @@ *

The version is kept per display because the SDK's freshness comparison is * per display: a change on one screen must not invalidate another's. * + *

Surfaces

+ * + *

JS measures with {@code measureInWindow}, which Fabric makes relative to + * the measured node's nearest {@code RootNodeKind} ancestor + * ({@code LayoutableShadowNode.cpp} stops the ancestor walk there; + * {@code ModalHostViewShadowNode} sets that trait). The activity React root + * and a {@code }'s {@code DialogRootViewGroup} are different surfaces: + * each rectangle is stored under the surface it was measured in, and served + * translated by that surface's display origin + * ({@code locationOnScreen - viewportOffset} of that root). A single origin + * for the whole display cannot serve both. + * *

Threading

* *

Writes arrive on the JS thread; the pull happens on a Bugsee background @@ -58,38 +71,86 @@ final class SecureRectangleStore { private static final int[] NO_COORDINATES = new int[0]; - /** One immutable published set. Replaced wholesale, never edited. */ - private static final class Snapshot { - final int version; - /** As JS published them: relative to React Native's viewport offset. */ + /** + * The activity React root's surface key. Legacy {@link #set}/{@link + * #setOrigin} writers that do not name a surface land here; a {@code + * }'s {@code DialogRootViewGroup} uses a different key. + */ + static final int MAIN_SURFACE = 0; + + /** One surface's raw measurements and the origin that places them. */ + private static final class Lane { + /** As JS published them: relative to this surface's viewport offset. */ final int[] raw; final int originX; final int originY; - /** {@link #raw} moved to the display origin: what the SDK is served. */ + /** {@link #raw} moved by this surface's origin: what the SDK is served. */ final int[] coordinates; - Snapshot(final int version, @NonNull final int[] raw, - final int originX, final int originY) { - this(version, raw, originX, originY, translate(raw, originX, originY)); + Lane(@NonNull final int[] raw, final int originX, final int originY) { + this(raw, originX, originY, translate(raw, originX, originY)); } - private Snapshot(final int version, @NonNull final int[] raw, - final int originX, final int originY, @NonNull final int[] coordinates) { - this.version = version; + private Lane(@NonNull final int[] raw, final int originX, final int originY, + @NonNull final int[] coordinates) { this.raw = raw; this.originX = originX; this.originY = originY; this.coordinates = coordinates; } + @NonNull + Lane withRaw(@NonNull final int[] newRaw) { + return new Lane(newRaw, originX, originY); + } + + @NonNull + Lane withOrigin(final int newOriginX, final int newOriginY) { + return new Lane(raw, newOriginX, newOriginY); + } + } + + /** One immutable published set for a display. Replaced wholesale. */ + private static final class Snapshot { + final int version; + /** Surface key โ†’ lane. TreeMap so the served order is stable. */ + final TreeMap lanes; + /** Every lane's translated coordinates, concatenated in surface-key order. */ + final int[] coordinates; + + Snapshot(final int version, @NonNull final TreeMap lanes) { + this.version = version; + // Defensive copy: callers must not retain a mutable reference. + this.lanes = new TreeMap<>(lanes); + this.coordinates = merge(this.lanes); + } + @NonNull Snapshot withVersion(final int newVersion) { - return new Snapshot(newVersion, raw, originX, originY, coordinates); + return new Snapshot(newVersion, lanes); + } + + @NonNull + private static int[] merge(@NonNull final TreeMap lanes) { + int total = 0; + for (final Lane lane : lanes.values()) { + total += lane.coordinates.length; + } + if (total == 0) { + return NO_COORDINATES; + } + final int[] merged = new int[total]; + int at = 0; + for (final Lane lane : lanes.values()) { + System.arraycopy(lane.coordinates, 0, merged, at, lane.coordinates.length); + at += lane.coordinates.length; + } + return merged; } } /** What a display serves before anything is secured or located. */ - private static final Snapshot EMPTY = new Snapshot(INITIAL_VERSION, NO_COORDINATES, 0, 0); + private static final Snapshot EMPTY = new Snapshot(INITIAL_VERSION, new TreeMap<>()); /** * The process-wide set of secured regions. @@ -111,13 +172,23 @@ static SecureRectangleStore shared() { private final Map byDisplay = new ConcurrentHashMap<>(); /** - * Publishes {@code coordinates} as the secure set for {@code display}, as a - * flat list of four-int rectangles. + * Publishes {@code coordinates} as the secure set for {@code display}'s + * main surface, as a flat list of four-int rectangles. Other surfaces on + * the same display are left alone. * - *

The version moves only when the set actually differs, so an app that - * re-publishes an unchanged layout on every render costs the SDK nothing. + *

The version moves only when the served set actually differs, so an + * app that re-publishes an unchanged layout on every render costs the SDK + * nothing. */ void set(final int display, @Nullable final int[] coordinates) { + set(display, MAIN_SURFACE, coordinates); + } + + /** + * Publishes {@code coordinates} for one surface on {@code display}. Other + * surfaces keep their rectangles and origins. + */ + void set(final int display, final int surface, @Nullable final int[] coordinates) { if (coordinates == null) { throw new IllegalArgumentException( "secure rectangles cannot be null; pass an empty array to clear them"); @@ -131,7 +202,7 @@ void set(final int display, @Nullable final int[] coordinates) { // Copy on the way in as well as out: the caller keeps its array and a // later write through it would edit a snapshot the SDK is reading. final int[] raw = coordinates.clone(); - update(display, previous -> new Snapshot(0, raw, previous.originX, previous.originY)); + update(display, previous -> withLane(previous, surface, lane -> lane.withRaw(raw))); } /** @@ -143,8 +214,12 @@ void set(final int display, @Nullable final int[] coordinates) { * @return whether {@code coordinates} was published */ boolean publishOrLog(final int display, @Nullable final int[] coordinates) { + return publishOrLog(display, MAIN_SURFACE, coordinates); + } + + boolean publishOrLog(final int display, final int surface, @Nullable final int[] coordinates) { try { - set(display, coordinates); + set(display, surface, coordinates); return true; } catch (IllegalArgumentException e) { Log.e(TAG, "setSecureRectangles rejected; the previous set stays published: " @@ -154,8 +229,9 @@ boolean publishOrLog(final int display, @Nullable final int[] coordinates) { } /** - * Records where the React root's viewport origin sits on {@code display}, - * in display pixels, and serves every rectangle moved by it. + * Records where the main React root's viewport origin sits on {@code + * display}, in display pixels, and serves that surface's rectangles moved + * by it. Other surfaces keep their own origins. * *

JS measures with {@code measureInWindow}, which React Native makes * relative to the root's viewport offset @@ -171,13 +247,23 @@ boolean publishOrLog(final int display, @Nullable final int[] coordinates) { * for integer {@code k}. */ void setOrigin(final int display, final int originX, final int originY) { - update(display, previous -> new Snapshot(0, previous.raw, originX, originY)); + setOrigin(display, MAIN_SURFACE, originX, originY); } /** - * {@code locationOnScreen} of the React root less React Native's viewport + * Records where one surface's viewport origin sits on {@code display}. + * Only that surface's rectangles move; a {@code } and the activity + * root must not share one origin. + */ + void setOrigin(final int display, final int surface, final int originX, final int originY) { + update(display, previous -> withLane(previous, surface, + lane -> lane.withOrigin(originX, originY))); + } + + /** + * {@code locationOnScreen} of a React root less React Native's viewport * offset for it: the display-pixel position of the origin - * {@code measureInWindow} measures from. + * {@code measureInWindow} measures from on that surface. */ @NonNull static int[] displayOrigin(@NonNull final int[] locationOnScreen, @@ -185,12 +271,27 @@ static int[] displayOrigin(@NonNull final int[] locationOnScreen, return new int[] { locationOnScreen[0] - viewportX, locationOnScreen[1] - viewportY }; } + private interface LaneChange { + @NonNull + Lane apply(@NonNull Lane previous); + } + private interface Change { /** The next state, built from the current one; its version is ignored. */ @NonNull Snapshot apply(@NonNull Snapshot previous); } + @NonNull + private static Snapshot withLane(@NonNull final Snapshot previous, final int surface, + @NonNull final LaneChange change) { + final TreeMap nextLanes = new TreeMap<>(previous.lanes); + final Lane prior = nextLanes.get(surface); + final Lane base = prior == null ? new Lane(NO_COORDINATES, 0, 0) : prior; + nextLanes.put(surface, change.apply(base)); + return new Snapshot(0, nextLanes); + } + /** * The version moves only when the SERVED set differs, so an app that * re-publishes an unchanged layout, or a layout pass that re-reports the diff --git a/packages/react-native/android/src/test/java/com/bugsee/reactnative/ReactRootOriginTrackerTest.java b/packages/react-native/android/src/test/java/com/bugsee/reactnative/ReactRootOriginTrackerTest.java index 87b9b610..0cc95809 100644 --- a/packages/react-native/android/src/test/java/com/bugsee/reactnative/ReactRootOriginTrackerTest.java +++ b/packages/react-native/android/src/test/java/com/bugsee/reactnative/ReactRootOriginTrackerTest.java @@ -2,6 +2,7 @@ import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import android.graphics.Point; @@ -58,6 +59,7 @@ private static final class FakeRoot implements RootHandle { int[] location = { 0, 0 }; Point viewport = new Point(); int displayId; + int surfaceKey = SecureRectangleStore.MAIN_SURFACE; /** Simulates the weakly-held view having been collected mid-refresh. */ boolean viewGone; int listenerRegistrations; @@ -68,6 +70,11 @@ public boolean isAttachedToWindow() { return attached; } + @Override + public int surfaceKey() { + return surfaceKey; + } + @Override public OriginSnapshot resolveOrigin() { if (viewGone) { @@ -329,6 +336,90 @@ public void currentOriginIsNullBeforeAnyRootIsFound() { assertNull(tracker.currentOrigin()); } + /** + * watchNow must keep the LayoutListenerToken it receives and release it + * when the handle leaves watched (detach, or a detached-root refresh). + * Dropping the token leaks the global-layout listener on the dialog root. + */ + @Test + public void detachingAWatchedSurfaceReleasesItsLayoutToken() { + final FakeRoot modal = new FakeRoot(); + modal.surfaceKey = 42; + final ReactRootOriginTracker tracker = new ReactRootOriginTracker( + new FakeLifecycleSource(), new QueueRootFinder(), new SecureRectangleStore()); + tracker.watchNow(modal); + final FakeToken token = modal.lastToken; + assertEquals(1, modal.listenerRegistrations); + assertNotNull(token); + + tracker.detach(); + + assertEquals("watchNow must retain the token so detach can release it", + 1, token.releaseCalls); + } + + @Test + public void aDetachedWatchedSurfaceIsReleasedOnRefresh() { + final FakeRoot modal = new FakeRoot(); + modal.surfaceKey = 42; + final ReactRootOriginTracker tracker = new ReactRootOriginTracker( + new FakeLifecycleSource(), new QueueRootFinder(), new SecureRectangleStore()); + tracker.watchNow(modal); + final FakeToken token = modal.lastToken; + + modal.attached = false; + tracker.refresh(); + + assertEquals(1, token.releaseCalls); + } + + /** + * A {@code }'s DialogRootViewGroup is a second surface. Its + * measureInWindow values are relative to that root, so the activity + * origin must not translate them, and its own origin must. + */ + @Test + public void aWatchedSurfacesOriginMovesOnlyItsRectangles() { + final FakeRoot main = new FakeRoot(); + main.location = new int[] { 0, 96 }; + main.viewport.x = 0; + main.viewport.y = 0; + main.displayId = 0; + main.surfaceKey = SecureRectangleStore.MAIN_SURFACE; + final FakeRoot modal = new FakeRoot(); + modal.location = new int[] { 40, 200 }; + modal.viewport.x = 0; + modal.viewport.y = 0; + modal.displayId = 0; + modal.surfaceKey = 42; + final QueueRootFinder finder = new QueueRootFinder(); + finder.queue.add(main); + final SecureRectangleStore store = new SecureRectangleStore(); + store.set(0, SecureRectangleStore.MAIN_SURFACE, new int[] { 10, 20, 30, 40 }); + store.set(0, 42, new int[] { 100, 50, 180, 90 }); + + final ReactRootOriginTracker tracker = + new ReactRootOriginTracker(new FakeLifecycleSource(), finder, store); + tracker.watchNow(modal); + tracker.refresh(); + + final int[] packed = store.snapshot(0); + assertEquals(2, packed[1]); + // Main origin (0, 96): (10,20)-(30,40) โ†’ (10,116)-(30,136). + // Modal origin (40, 200): (100,50)-(180,90) โ†’ (140,250)-(220,290). + assertArrayEquals( + new int[] { 10, 116, 30, 136, 140, 250, 220, 290 }, + java.util.Arrays.copyOfRange(packed, 2, packed.length)); + + // Moving only the activity root must not drag the modal rectangle. + main.location = new int[] { 0, 200 }; + tracker.refresh(); + final int[] afterMainMove = store.snapshot(0); + assertArrayEquals( + new int[] { 10, 220, 30, 240, 140, 250, 220, 290 }, + java.util.Arrays.copyOfRange(afterMainMove, 2, afterMainMove.length)); + } + @Test public void constructorRegistersAsALifecycleListener() { final FakeLifecycleSource lifecycle = new FakeLifecycleSource(); diff --git a/packages/react-native/android/src/test/java/com/bugsee/reactnative/SecureRectangleStoreTest.java b/packages/react-native/android/src/test/java/com/bugsee/reactnative/SecureRectangleStoreTest.java index 67a3f0db..7313bb47 100644 --- a/packages/react-native/android/src/test/java/com/bugsee/reactnative/SecureRectangleStoreTest.java +++ b/packages/react-native/android/src/test/java/com/bugsee/reactnative/SecureRectangleStoreTest.java @@ -276,6 +276,60 @@ public void theDisplayOriginIsTheScreenLocationLessTheViewportOffset() { SecureRectangleStore.displayOrigin(new int[] { 80, 1296 }, 0, 96)); } + // ---- Per-surface origins (Modal / DialogRootViewGroup) -------------- + // + // Fabric measureInWindow stops at the nearest RootNodeKind ancestor + // (ModalHostViewShadowNode sets that trait), so a rectangle measured + // inside a is already relative to THAT surface's root -- not + // the activity React root. One setOrigin for the whole display cannot + // serve both: the activity origin must not move the modal's rectangle, + // and the modal surface's own origin must. + + private static final int MAIN_SURFACE = 0; + private static final int MODAL_SURFACE = 42; + + @Test + public void theMainOriginDoesNotMoveAnotherSurfacesRectangle() { + final SecureRectangleStore store = new SecureRectangleStore(); + store.set(DISPLAY, MAIN_SURFACE, new int[] { 10, 20, 30, 40 }); + store.set(DISPLAY, MODAL_SURFACE, new int[] { 100, 200, 150, 250 }); + store.setOrigin(DISPLAY, MAIN_SURFACE, 0, 96); + store.setOrigin(DISPLAY, MODAL_SURFACE, 0, 0); + + final int[] packed = store.snapshot(DISPLAY); + assertEquals(2, packed[1]); + assertArrayEquals( + new int[] { 10, 116, 30, 136, 100, 200, 150, 250 }, + java.util.Arrays.copyOfRange(packed, 2, packed.length)); + } + + @Test + public void anotherSurfacesOwnOriginMovesOnlyItsRectangle() { + final SecureRectangleStore store = new SecureRectangleStore(); + store.set(DISPLAY, MAIN_SURFACE, new int[] { 10, 20, 30, 40 }); + store.set(DISPLAY, MODAL_SURFACE, new int[] { 100, 200, 150, 250 }); + store.setOrigin(DISPLAY, MAIN_SURFACE, 0, 96); + store.setOrigin(DISPLAY, MODAL_SURFACE, 7, 40); + + final int[] packed = store.snapshot(DISPLAY); + assertEquals(2, packed[1]); + assertArrayEquals( + new int[] { 10, 116, 30, 136, 107, 240, 157, 290 }, + java.util.Arrays.copyOfRange(packed, 2, packed.length)); + } + + /** Existing single-argument set/setOrigin keep meaning the main surface. */ + @Test + public void theLegacySetAndSetOriginTargetTheMainSurface() { + final SecureRectangleStore store = new SecureRectangleStore(); + store.set(DISPLAY, new int[] { 10, 20, 30, 40 }); + store.setOrigin(DISPLAY, 0, 96); + + final int[] packed = store.snapshot(DISPLAY); + assertArrayEquals(new int[] { 10, 116, 30, 136 }, + java.util.Arrays.copyOfRange(packed, 2, packed.length)); + } + // ---- The TurboModule's entry point ---------------------------------- // // setSecureRectangles is a void TurboModule method: an exception thrown From 76eeb5645a7567e7b9ab7c7d7e0868e5c8a72845 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Sun, 4 Oct 2026 22:33:46 +0500 Subject: [PATCH 02/19] fix(ios): keep per-surface secure lanes with their own origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BGSRNSecureRectangles stores each surface's raw rectangles with the origin that places them and serves the lanes merged in surface-key order; the version moves only when the served bytes change, and an origin-only write that leaves the set empty still reports version 1. The legacy single-surface API means BGSRNSecureMainSurface. On iOS a Fabric is presentViewController: on the same UIWindow, so it shares the main surface and that window's frame.origin; only Android dialogs use a second key. Ported from feat/phase-6-modal (9ec12a2, e6e886f) onto main. TDD: the two-surface and version-1 XCTests did not compile against main's store (no surface/origin API). Mutation: translating every lane by the main surface's origin fails testTheMainOriginDoesNotMoveAnotherSurfacesRectangle and testAnotherSurfacesOwnOriginMovesOnlyItsRectangle; reverted. BugseeRNSupport: 237 tests, 0 failures. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --- .../BugseeRNSupport/BGSRNSecureRectangles.m | 154 +++++++++++++++--- .../include/BGSRNSecureRectangles.h | 32 +++- .../BGSRNSecureRectanglesTests.m | 42 +++++ 3 files changed, 203 insertions(+), 25 deletions(-) diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m index 56381d7c..22627c72 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m @@ -10,6 +10,10 @@ /// as it is stable; the SDK only ever compares it against what it saw last. static const int32_t kInitialVersion = 1; +/// The activity / main React root's surface key. Legacy writers that do not +/// name a surface land here. +const NSInteger BGSRNSecureMainSurface = 0; + /// Steps the version, skipping the value that means "nothing secured yet": /// landing back on it would make a real set look identical to the empty one to /// an SDK that had only ever seen the latter. Wrapping takes 2^31 changes, so @@ -19,14 +23,56 @@ static int32_t BGSRNNextVersion(int32_t current) { return next == kInitialVersion ? kInitialVersion + 1 : next; } +static int32_t BGSRNSaturatedAdd(int32_t a, int32_t b) { + const int64_t sum = (int64_t)a + (int64_t)b; + if (sum > INT32_MAX) { + return INT32_MAX; + } + if (sum < INT32_MIN) { + return INT32_MIN; + } + return (int32_t)sum; +} + +/// One surface's raw measurements and the origin that places them. +@interface BGSRNSecureLane : NSObject +@property (nonatomic, copy) NSData *raw; +@property (nonatomic, assign) int32_t originX; +@property (nonatomic, assign) int32_t originY; +@property (nonatomic, copy, readonly) NSData *coordinates; +@end + +@implementation BGSRNSecureLane + +- (instancetype)initWithRaw:(NSData *)raw originX:(int32_t)originX originY:(int32_t)originY { + self = [super init]; + if (self) { + _raw = [raw copy] ?: [NSData data]; + _originX = originX; + _originY = originY; + const NSUInteger count = _raw.length / sizeof(int32_t); + if (count == 0) { + _coordinates = [NSData data]; + } else { + NSMutableData *moved = [NSMutableData dataWithLength:count * sizeof(int32_t)]; + const int32_t *in = (const int32_t *)_raw.bytes; + int32_t *out = (int32_t *)moved.mutableBytes; + for (NSUInteger i = 0; i < count; i++) { + out[i] = BGSRNSaturatedAdd(in[i], (i % 2 == 0) ? originX : originY); + } + _coordinates = [moved copy]; + } + } + return self; +} + +@end + @implementation BGSRNSecureRectangles { - /// display -> the coordinates last published for it, as NSData of int32. - NSMutableDictionary *_coordinatesByDisplay; + /// display -> (surface -> lane). Surfaces sorted by key when serving. + NSMutableDictionary *> *_lanesByDisplay; /// display -> its current version. NSMutableDictionary *_versionsByDisplay; - /// Serialises the JS-thread write against the main-thread pull. A plain lock - /// rather than a queue: the pull happens on the SDK's own thread 2-3 times a - /// second and must not be made to hop. NSLock *_lock; } @@ -42,7 +88,7 @@ + (BGSRNSecureRectangles *)shared { - (instancetype)init { self = [super init]; if (self) { - _coordinatesByDisplay = [NSMutableDictionary dictionary]; + _lanesByDisplay = [NSMutableDictionary dictionary]; _versionsByDisplay = [NSMutableDictionary dictionary]; _lock = [[NSLock alloc] init]; } @@ -52,6 +98,13 @@ - (instancetype)init { - (BOOL)setCoordinates:(const int32_t *)coordinates count:(NSUInteger)count forDisplay:(NSInteger)display { + return [self setCoordinates:coordinates count:count forDisplay:display surface:BGSRNSecureMainSurface]; +} + +- (BOOL)setCoordinates:(const int32_t *)coordinates + count:(NSUInteger)count + forDisplay:(NSInteger)display + surface:(NSInteger)surface { if (count % kCoordinatesPerRectangle != 0) { return NO; } @@ -59,43 +112,99 @@ - (BOOL)setCoordinates:(const int32_t *)coordinates return NO; } - // Copied on the way in: the caller keeps its buffer, and a later write - // through it would edit a snapshot the SDK is reading. NSData *published = count == 0 ? [NSData data] : [NSData dataWithBytes:coordinates length:count * sizeof(int32_t)]; - NSNumber *key = @(display); + NSNumber *displayKey = @(display); + NSNumber *surfaceKey = @(surface); + [_lock lock]; - NSData *previous = _coordinatesByDisplay[key]; - if (previous == nil || ![previous isEqualToData:published]) { - const int32_t currentVersion = - previous == nil ? kInitialVersion : _versionsByDisplay[key].intValue; - _coordinatesByDisplay[key] = published; - _versionsByDisplay[key] = @(BGSRNNextVersion(currentVersion)); + NSData *previousServed = [self servedCoordinatesForDisplayLocked:display]; + NSMutableDictionary *lanes = _lanesByDisplay[displayKey]; + if (lanes == nil) { + lanes = [NSMutableDictionary dictionary]; + _lanesByDisplay[displayKey] = lanes; + } + BGSRNSecureLane *prior = lanes[surfaceKey]; + int32_t originX = prior ? prior.originX : 0; + int32_t originY = prior ? prior.originY : 0; + lanes[surfaceKey] = [[BGSRNSecureLane alloc] initWithRaw:published originX:originX originY:originY]; + NSData *nextServed = [self servedCoordinatesForDisplayLocked:display]; + if (![previousServed isEqualToData:nextServed]) { + const int32_t currentVersion = _versionsByDisplay[displayKey] + ? _versionsByDisplay[displayKey].intValue + : kInitialVersion; + _versionsByDisplay[displayKey] = @(BGSRNNextVersion(currentVersion)); } [_lock unlock]; return YES; } +- (void)setOriginX:(int32_t)originX originY:(int32_t)originY forDisplay:(NSInteger)display { + [self setOriginX:originX originY:originY forDisplay:display surface:BGSRNSecureMainSurface]; +} + +- (void)setOriginX:(int32_t)originX + originY:(int32_t)originY + forDisplay:(NSInteger)display + surface:(NSInteger)surface { + NSNumber *displayKey = @(display); + NSNumber *surfaceKey = @(surface); + + [_lock lock]; + NSData *previousServed = [self servedCoordinatesForDisplayLocked:display]; + NSMutableDictionary *lanes = _lanesByDisplay[displayKey]; + if (lanes == nil) { + lanes = [NSMutableDictionary dictionary]; + _lanesByDisplay[displayKey] = lanes; + } + BGSRNSecureLane *prior = lanes[surfaceKey]; + NSData *raw = prior ? prior.raw : [NSData data]; + lanes[surfaceKey] = [[BGSRNSecureLane alloc] initWithRaw:raw originX:originX originY:originY]; + NSData *nextServed = [self servedCoordinatesForDisplayLocked:display]; + if (![previousServed isEqualToData:nextServed]) { + const int32_t currentVersion = _versionsByDisplay[displayKey] + ? _versionsByDisplay[displayKey].intValue + : kInitialVersion; + _versionsByDisplay[displayKey] = @(BGSRNNextVersion(currentVersion)); + } + [_lock unlock]; +} + +/// Caller holds `_lock`. +- (NSData *)servedCoordinatesForDisplayLocked:(NSInteger)display { + NSMutableDictionary *lanes = _lanesByDisplay[@(display)]; + if (lanes.count == 0) { + return [NSData data]; + } + NSArray *keys = [lanes.allKeys sortedArrayUsingSelector:@selector(compare:)]; + NSMutableData *merged = [NSMutableData data]; + for (NSNumber *key in keys) { + [merged appendData:lanes[key].coordinates]; + } + return [merged copy]; +} + - (NSData *)snapshotForDisplay:(NSInteger)display { NSNumber *key = @(display); [_lock lock]; - NSData *coordinates = _coordinatesByDisplay[key]; - const int32_t version = - coordinates == nil ? kInitialVersion : _versionsByDisplay[key].intValue; + NSData *coordinates = [self servedCoordinatesForDisplayLocked:display]; + // Lanes can exist without a version entry: an origin-only write that leaves + // the served buffer empty deliberately does not bump. Messaging a missing + // NSNumber yields 0, which is not the empty-set version (kInitialVersion). + const int32_t version = _versionsByDisplay[key] + ? _versionsByDisplay[key].intValue + : kInitialVersion; [_lock unlock]; - const NSUInteger coordinateCount = - coordinates == nil ? 0 : coordinates.length / sizeof(int32_t); + const NSUInteger coordinateCount = coordinates.length / sizeof(int32_t); NSMutableData *packed = [NSMutableData dataWithLength:(kHeaderInts + coordinateCount) * sizeof(int32_t)]; int32_t *out = (int32_t *)packed.mutableBytes; - // The SDK reads raw int32s, so the encoding is explicit rather than whatever - // this host happens to be. out[0] = (int32_t)CFSwapInt32HostToLittle((uint32_t)version); out[1] = (int32_t)CFSwapInt32HostToLittle( (uint32_t)(coordinateCount / kCoordinatesPerRectangle)); @@ -107,7 +216,6 @@ - (NSData *)snapshotForDisplay:(NSInteger)display { } } - // Immutable to the caller: the SDK may hold it across our next write. return [packed copy]; } diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSecureRectangles.h b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSecureRectangles.h index e3e9e751..cecd3f08 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSecureRectangles.h +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSecureRectangles.h @@ -2,6 +2,10 @@ NS_ASSUME_NONNULL_BEGIN +/// The activity / main React root's surface key. Legacy writers that do not +/// name a surface land here. +FOUNDATION_EXPORT const NSInteger BGSRNSecureMainSurface; + /// The regions the app has asked Bugsee not to record, in the form the SDK /// pulls them. /// @@ -20,6 +24,14 @@ NS_ASSUME_NONNULL_BEGIN /// The version is kept per display, because the SDK's freshness comparison is /// per display: a change on one screen must not invalidate another's. /// +/// Fabric `measureInWindow` is relative to the measured node's nearest +/// `RootNodeKind` ancestor. On Android a `` dialog is a second +/// surface key with its own origin; rectangles are stored under the surface +/// they were measured in and served translated by that surface's origin. On +/// iOS a Fabric Modal is `presentViewController:` on the same `UIWindow`, so +/// it publishes on `BGSRNSecureMainSurface` with that window's +/// `frame.origin` โ€” not a second origin lane. +/// /// ## Threading /// /// Writes arrive on the JS thread; the pull is on the main thread. Every @@ -37,8 +49,7 @@ NS_ASSUME_NONNULL_BEGIN /// forgotten when that happens. @property (class, readonly) BGSRNSecureRectangles *shared; -/// Publishes `coordinates` as the secure set for `display`, as a flat list of -/// four-`int32` rectangles. +/// Publishes `coordinates` as the secure set for `display`'s main surface. /// /// @param coordinates may be NULL only when `count` is 0. /// @param count number of int32s, which must be a multiple of 4. @@ -47,6 +58,23 @@ NS_ASSUME_NONNULL_BEGIN count:(NSUInteger)count forDisplay:(NSInteger)display; +/// Publishes `coordinates` for one surface on `display`. Other surfaces keep +/// their rectangles and origins. +- (BOOL)setCoordinates:(nullable const int32_t *)coordinates + count:(NSUInteger)count + forDisplay:(NSInteger)display + surface:(NSInteger)surface; + +/// Records where the main React root's viewport origin sits on `display`. +- (void)setOriginX:(int32_t)originX originY:(int32_t)originY forDisplay:(NSInteger)display; + +/// Records where one surface's origin sits on `display`. Only that surface's +/// rectangles move. +- (void)setOriginX:(int32_t)originX + originY:(int32_t)originY + forDisplay:(NSInteger)display + surface:(NSInteger)surface; + /// The buffer for `display`. A display nothing has secured reports an empty /// set rather than nil, so the SDK always has a version to compare against. - (NSData *)snapshotForDisplay:(NSInteger)display; diff --git a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m index 714d2f9a..de8ac82f 100644 --- a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m +++ b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m @@ -144,6 +144,48 @@ - (void)testIgnoresACoordinateListThatIsNotWholeRectangles { XCTAssertEqualObjects(packed[1], @0, @"a rejected write must not publish anything"); } +/// An origin-only write that leaves the served buffer empty must keep the +/// snapshot at the empty-set version (1), not message-nil's 0. Android's +/// empty snapshot stays at version 1 the same way. +- (void)testAnOriginOnlyWriteThatLeavesServedEmptyKeepsVersionOne { + [_store setOriginX:0 originY:96 forDisplay:0]; + + NSArray *packed = [self unpack:[_store snapshotForDisplay:0]]; + XCTAssertEqualObjects(packed[0], @1, + @"origin-only with no rectangles must not report version 0"); + XCTAssertEqualObjects(packed[1], @0); +} + +/// The main origin must not move another surface's rectangle, and that +/// surface's own origin must. +- (void)testTheMainOriginDoesNotMoveAnotherSurfacesRectangle { + const int32_t mainRects[] = {10, 20, 30, 40}; + const int32_t modalRects[] = {100, 200, 150, 250}; + [_store setCoordinates:mainRects count:4 forDisplay:0 surface:BGSRNSecureMainSurface]; + [_store setCoordinates:modalRects count:4 forDisplay:0 surface:42]; + [_store setOriginX:0 originY:96 forDisplay:0 surface:BGSRNSecureMainSurface]; + [_store setOriginX:0 originY:0 forDisplay:0 surface:42]; + + NSArray *packed = [self unpack:[_store snapshotForDisplay:0]]; + XCTAssertEqualObjects(packed[1], @2); + XCTAssertEqualObjects([packed subarrayWithRange:NSMakeRange(2, 8)], + (@[ @10, @116, @30, @136, @100, @200, @150, @250 ])); +} + +- (void)testAnotherSurfacesOwnOriginMovesOnlyItsRectangle { + const int32_t mainRects[] = {10, 20, 30, 40}; + const int32_t modalRects[] = {100, 200, 150, 250}; + [_store setCoordinates:mainRects count:4 forDisplay:0 surface:BGSRNSecureMainSurface]; + [_store setCoordinates:modalRects count:4 forDisplay:0 surface:42]; + [_store setOriginX:0 originY:96 forDisplay:0 surface:BGSRNSecureMainSurface]; + [_store setOriginX:7 originY:40 forDisplay:0 surface:42]; + + NSArray *packed = [self unpack:[_store snapshotForDisplay:0]]; + XCTAssertEqualObjects(packed[1], @2); + XCTAssertEqualObjects([packed subarrayWithRange:NSMakeRange(2, 8)], + (@[ @10, @116, @30, @136, @107, @240, @157, @290 ])); +} + /// The shared store outlives any one wrapper: the init provider registers one /// before launch and setWrapperInfo swaps in another, and the regions the app /// marked secret must survive that. From 3d58f09e52076a6bbf6666eed9c566dd2eca3b4d Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Sun, 4 Oct 2026 22:44:19 +0500 Subject: [PATCH 03/19] fix(privacy): publish and managed nodes on their own surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabric measureInWindow is relative to the measured node's nearest RootNodeKind ancestor, and a is one (an Android Dialog). The spec gains setSecureRectanglesOnSurface, secureSurfaceKey and secureSurfaceOrigin. The registry keeps one union per display and surface and publishes a non-main surface through setSecureRectanglesOnSurface; asks native for the surface key of the root that holds its view (main surface when it has no tag or the answer is not an integer); the view-tree walk places each node by the origin of its own React root, falling back to the request's origin. Android resolves the root from the view tag on the UI thread, watches it, and returns that root's locationOnScreen - viewportOffset. iOS Fabric presents a Modal on the same UIWindow, so it answers the main surface and the hosting window's frame.origin, and refreshes that origin on every pull. New log lines carry a class name only. Ported from feat/phase-6-modal (9ec12a2, e6e886f) onto main. TDD: the per-surface registry tests failed against a surface-ignoring stub (8 of 9); the BugseeSecure surface tests failed 3 of 15 before the lookup; the walk and requests env tests failed to compile / 15 of 15 before the env fields existed. Mutation: placing every node by the request origin fails 3 walk/requests tests; reverted. Stryker on the four changed files: 95.69%, survivors in new code are equivalent (a no-op republish, a null read inside a try). ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --- .../com/bugsee/reactnative/BugseeModule.java | 137 ++++++++++++++++- packages/react-native/ios/BugseeModule.mm | 66 ++++++++- packages/react-native/src/NativeBugsee.ts | 27 +++- packages/react-native/src/__mocks__/native.ts | 6 + .../react-native/src/secure/BugseeSecure.tsx | 30 +++- .../secure/__tests__/BugseeSecure.test.tsx | 90 +++++++++++ .../src/secure/__tests__/registry.test.ts | 140 ++++++++++++++++++ packages/react-native/src/secure/registry.ts | 76 ++++++---- .../src/viewtree/__tests__/requests.test.ts | 106 ++++++++++++- .../src/viewtree/__tests__/walk.test.ts | 71 +++++++++ .../react-native/src/viewtree/requests.ts | 31 ++++ packages/react-native/src/viewtree/walk.ts | 40 +++-- 12 files changed, 768 insertions(+), 52 deletions(-) diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java index 93a125d8..29c4438f 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java @@ -3,8 +3,10 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import android.graphics.Point; import android.util.Base64; import android.util.Log; +import android.view.View; import com.bugsee.library.Bugsee; import com.bugsee.library.contracts.exchange.Breadcrumb; @@ -23,10 +25,14 @@ import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.ReadableMapKeySetIterator; import com.facebook.react.bridge.ReadableType; -import com.facebook.react.bridge.WritableArray; +import com.facebook.react.bridge.UIManager; import com.facebook.react.bridge.UiThreadUtil; +import com.facebook.react.bridge.WritableArray; import com.facebook.react.bridge.WritableMap; import com.facebook.react.module.annotations.ReactModule; +import com.facebook.react.uimanager.RootView; +import com.facebook.react.uimanager.RootViewUtil; +import com.facebook.react.uimanager.UIManagerHelper; import java.io.Serializable; import java.util.ArrayList; @@ -35,7 +41,10 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import org.json.JSONException; import org.json.JSONObject; @@ -246,6 +255,16 @@ public void setWrapperInfo(final ReadableMap identity) { @Override public void setSecureRectangles(final double display, final ReadableArray coordinates) { + publishSecure(display, SecureRectangleStore.MAIN_SURFACE, coordinates); + } + + @Override + public void setSecureRectanglesOnSurface( + final double display, final double surface, final ReadableArray coordinates) { + publishSecure(display, (int) Math.round(surface), coordinates); + } + + private void publishSecure(final double display, final int surface, final ReadableArray coordinates) { // A void TurboModule method: anything thrown here has no promise to // reject and crashes the host app, over a call JS already validated. try { @@ -258,16 +277,17 @@ public void setSecureRectangles(final double display, final ReadableArray coordi for (int i = 0; i < flat.length; i++) { flat[i] = (int) Math.round(coordinates.getDouble(i)); } - // Stored as measured (relative to React Native's viewport - // offset); the store serves them moved to the React root's - // display origin, which the tracker keeps current. Re-read now - // too, in case the window moved without a layout pass. - if (!SecureRectangleStore.shared().publishOrLog((int) display, flat)) { + // Stored as measured (relative to that surface's viewport + // offset); the store serves them moved by that surface's display + // origin, which the tracker keeps current. Re-read now too, in + // case the window moved without a layout pass. + if (!SecureRectangleStore.shared().publishOrLog((int) display, surface, flat)) { return; } originTracker.refreshSoon(); if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "secure published display=" + (int) display + + " surface=" + surface + " raw=" + Arrays.toString(flat) + " served=" + Arrays.toString(SecureRectangleStore.shared().snapshot((int) display))); } @@ -277,6 +297,111 @@ public void setSecureRectangles(final double display, final ReadableArray coordi } } + @Override + public double secureSurfaceKey(final double viewTag) { + return resolveOnUiThread( + SecureRectangleStore.MAIN_SURFACE, + () -> surfaceKeyForViewTag((int) Math.round(viewTag))); + } + + @Override + public WritableArray secureSurfaceOrigin(final double viewTag) { + final int[] origin = resolveOnUiThread( + null, + () -> surfaceOriginForViewTag((int) Math.round(viewTag))); + final WritableArray out = Arguments.createArray(); + if (origin != null) { + out.pushDouble(origin[0]); + out.pushDouble(origin[1]); + } + return out; + } + + @Nullable + private int[] surfaceOriginForViewTag(final int viewTag) { + final View rootView = rootViewForTag(viewTag); + if (rootView == null) { + return null; + } + final int[] onScreen = new int[2]; + rootView.getLocationOnScreen(onScreen); + final Point viewport = RootViewUtil.getViewportOffset(rootView); + return SecureRectangleStore.displayOrigin(onScreen, viewport.x, viewport.y); + } + + private int surfaceKeyForViewTag(final int viewTag) { + final View rootView = rootViewForTag(viewTag); + if (rootView == null) { + return SecureRectangleStore.MAIN_SURFACE; + } + final ReactRootOriginTracker.RootHandle handle = ReactRootOriginTracker.handleFor(rootView); + if (handle == null) { + return SecureRectangleStore.MAIN_SURFACE; + } + originTracker.watchNow(handle); + return handle.surfaceKey(); + } + + @Nullable + private View rootViewForTag(final int viewTag) { + final ReactApplicationContext context = getReactApplicationContext(); + if (context == null || !context.hasActiveReactInstance()) { + return null; + } + try { + final UIManager uiManager = UIManagerHelper.getUIManagerForReactTag(context, viewTag); + if (uiManager == null) { + return null; + } + final View view = uiManager.resolveView(viewTag); + if (view == null) { + return null; + } + final RootView root = RootViewUtil.getRootView(view); + return root instanceof View ? (View) root : null; + } catch (Throwable t) { + Log.w(TAG, "secure surface: could not resolve the React root (" + + t.getClass().getSimpleName() + ")"); + return null; + } + } + + /** + * Runs {@code work} on the UI thread and returns its result. View and + * RootView access is not safe off the UI thread; {@code + * secureSurfaceKey}/{@code secureSurfaceOrigin} are sync TurboModule + * methods called from the JS thread during measure. + */ + private T resolveOnUiThread(final T fallback, final java.util.concurrent.Callable work) { + if (UiThreadUtil.isOnUiThread()) { + try { + return work.call(); + } catch (Exception e) { + Log.w(TAG, "secure surface resolve failed (" + e.getClass().getSimpleName() + ")"); + return fallback; + } + } + final AtomicReference result = new AtomicReference<>(fallback); + final CountDownLatch done = new CountDownLatch(1); + UiThreadUtil.runOnUiThread(() -> { + try { + result.set(work.call()); + } catch (Exception e) { + Log.w(TAG, "secure surface resolve failed (" + e.getClass().getSimpleName() + ")"); + } finally { + done.countDown(); + } + }); + try { + if (!done.await(2, TimeUnit.SECONDS)) { + Log.w(TAG, "secure surface resolve timed out"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return result.get(); + } + // --- Blackout and view-hierarchy capture (design doc ยง4.1) ----------- // Android ignores startBlackout/endBlackout before launch() (a logged // no-op inside the SDK itself); iOS honours them regardless. Recorded in diff --git a/packages/react-native/ios/BugseeModule.mm b/packages/react-native/ios/BugseeModule.mm index 8768bc91..97c2267a 100644 --- a/packages/react-native/ios/BugseeModule.mm +++ b/packages/react-native/ios/BugseeModule.mm @@ -65,6 +65,10 @@ #import "BGSRNErrorMessage.h" #endif +/// Forward-declared: the wrapper's pull path refreshes the origin before the +/// function's definition later in this file. +static NSValue *_Nullable BGSRNReactOrigin(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 @@ -105,6 +109,20 @@ - (void)onLifecycleEvent:(NSString *)eventType data:(id)data { /// secret must survive that swap. See `BGSRNSecureRectangles` for the version /// contract, which is what makes the SDK notice a change at all. - (NSData *)secureRectanglesForDisplay:(NSInteger)display { + // Refresh the main surface origin on each pull so a window move still + // updates without waiting for the next JS publish โ€” same reason Android + // refreshes at pull time. iOS Fabric Modal shares this window + // (`presentViewController:`), so one origin serves both. + if (NSThread.isMainThread) { + NSValue *origin = BGSRNReactOrigin(); + if (origin != nil) { + CGPoint p = origin.CGPointValue; + [BGSRNSecureRectangles.shared setOriginX:(int32_t)llround(p.x) + originY:(int32_t)llround(p.y) + forDisplay:display + surface:BGSRNSecureMainSurface]; + } + } return [BGSRNSecureRectangles.shared snapshotForDisplay:display]; } @@ -957,6 +975,14 @@ + (BOOL)requiresMainQueueSetup { - (void)setSecureRectangles:(double)display coordinates:(NSArray *)coordinates { + [self setSecureRectanglesOnSurface:display + surface:BGSRNSecureMainSurface + coordinates:coordinates]; +} + +- (void)setSecureRectanglesOnSurface:(double)display + surface:(double)surface + coordinates:(NSArray *)coordinates { const NSUInteger count = coordinates.count; // Codegen hands numbers across as double, because that is what a JS number // is. Rounding rather than truncating: the JS side has already rounded each @@ -972,10 +998,48 @@ - (void)setSecureRectangles:(double)display [BGSRNSecureRectangles.shared setCoordinates:flat count:count - forDisplay:(NSInteger)display]; + forDisplay:(NSInteger)display + surface:(NSInteger)llround(surface)]; free(flat); } +/// The store key for the React root that hosts `viewTag`. On iOS Fabric a +/// `` is presented in the same `UIWindow` as the main surface +/// (`RCTModalHostViewComponentView` uses `presentViewController:`), so both +/// publish on the main surface. Fabric `measureInWindow` for modal content +/// stops at `ModalHostView`'s `RootNodeKind` with an identity transform +/// (`LayoutableShadowNode.cpp`, `ModalHostViewShadowNode.h`), which is +/// already that window's coordinate space; the origin is the window's +/// `frame.origin` (what the SDK adds to every native node โ€” see +/// `BGSRNReactWindow.h`). +- (NSNumber *)secureSurfaceKey:(double)viewTag { + (void)viewTag; + BGSRNRunOnMainSync(^{ + NSValue *origin = BGSRNReactOrigin(); + if (origin != nil) { + CGPoint p = origin.CGPointValue; + [BGSRNSecureRectangles.shared setOriginX:(int32_t)llround(p.x) + originY:(int32_t)llround(p.y) + forDisplay:0 + surface:BGSRNSecureMainSurface]; + } + }); + return @(BGSRNSecureMainSurface); +} + +- (NSArray *)secureSurfaceOrigin:(double)viewTag { + (void)viewTag; + __block NSArray *origin = @[]; + BGSRNRunOnMainSync(^{ + NSValue *value = BGSRNReactOrigin(); + if (value != nil) { + CGPoint p = value.CGPointValue; + origin = @[ @(p.x), @(p.y) ]; + } + }); + return origin; +} + #pragma mark - Blackout and view-hierarchy capture (design doc ยง4.1) // iOS honours startBlackout/endBlackout regardless of launch state, unlike diff --git a/packages/react-native/src/NativeBugsee.ts b/packages/react-native/src/NativeBugsee.ts index da4e8302..9c6929c3 100644 --- a/packages/react-native/src/NativeBugsee.ts +++ b/packages/react-native/src/NativeBugsee.ts @@ -56,9 +56,34 @@ export interface Spec extends TurboModule { * has to answer immediately, and a rejected one would leave the caller * believing a region is redacted when it is not. * - * An empty list clears the display's set. + * An empty list clears the display's main-surface set. Rectangles measured + * inside a React Native `` go through + * {@link setSecureRectanglesOnSurface} with that surface's key from + * {@link secureSurfaceKey}. */ setSecureRectangles(display: number, coordinates: number[]): void; + /** + * Like {@link setSecureRectangles}, for one React surface on the display. + * Fabric `measureInWindow` is relative to that surface's root + * (`ModalHostView` / `DialogRootViewGroup` for a ``), so each + * surface keeps its own origin at pull time. + */ + setSecureRectanglesOnSurface( + display: number, + surface: number, + coordinates: number[], + ): void; + /** + * The store key for the React root that hosts `viewTag` (the activity root + * or a modal's dialog root). Registers that root for origin refresh. + * Returns {@code 0} (the main surface) when the view cannot be resolved. + */ + secureSurfaceKey(viewTag: number): number; + /** + * Display origin `[x, y]` of the React root hosting `viewTag`, in the same + * units as the `vh` request's `originX`/`originY`. Empty when unknown. + */ + secureSurfaceOrigin(viewTag: number): number[]; /** * Suppresses everything that describes the screen: the video (black diff --git a/packages/react-native/src/__mocks__/native.ts b/packages/react-native/src/__mocks__/native.ts index 653332da..6cdadced 100644 --- a/packages/react-native/src/__mocks__/native.ts +++ b/packages/react-native/src/__mocks__/native.ts @@ -20,6 +20,9 @@ const SYNC_RETURNS: Record = { noteConsoleEcho: true, setAppearanceColor: true, getAppearanceColor: '', + // An unresolvable view: the main surface, and no origin of its own. + secureSurfaceKey: 0, + secureSurfaceOrigin: [], }; const DEFAULTS: Record = { @@ -108,6 +111,9 @@ let breadcrumbFilterRequestSubscribeCalls = 0; export const native = { setWrapperInfo: jest.fn]>(), setSecureRectangles: jest.fn(), + setSecureRectanglesOnSurface: jest.fn(), + secureSurfaceKey: jest.fn(), + secureSurfaceOrigin: jest.fn(), startBlackout: jest.fn(), endBlackout: jest.fn(), isBlackout: jest.fn, []>(), diff --git a/packages/react-native/src/secure/BugseeSecure.tsx b/packages/react-native/src/secure/BugseeSecure.tsx index 0aacf73d..79bc16c6 100644 --- a/packages/react-native/src/secure/BugseeSecure.tsx +++ b/packages/react-native/src/secure/BugseeSecure.tsx @@ -2,8 +2,9 @@ import { useLayoutEffect, useRef } from 'react'; import type { ComponentRef, ReactElement, ReactNode } from 'react'; import { View } from 'react-native'; import type { LayoutChangeEvent, ViewProps } from 'react-native'; +import NativeBugsee from '../NativeBugsee'; import { addMeasurer } from './measureLoop'; -import { clearOwner, setOwnerRectangles } from './registry'; +import { clearOwner, MAIN_SURFACE, setOwnerRectangles } from './registry'; import { errorName } from '../errorName'; export interface BugseeSecureProps extends ViewProps { @@ -25,10 +26,11 @@ export interface BugseeSecureProps extends ViewProps { * Fails closed: a measurement that throws or is rejected leaves the last * rectangle published. Only unmounting or `enabled={false}` removes it. * - * The rectangle is in the main React root's window. Android adds that root's - * display origin to `measureInWindow`. A React Native `` is its own - * window (an Android `Dialog`), so a secure view inside one is not placed on - * the sheet. Same limit for `managed` nodes measured the same way. + * Fabric `measureInWindow` is relative to the measured node's nearest + * `RootNodeKind` ancestor (the activity React root, or a ``'s + * `DialogRootViewGroup` / `ModalHostView`). The rectangle is published under + * that surface's key so native can translate it by that surface's display + * origin at pull time โ€” not the activity root's origin alone. * * A plain function component on purpose (no `memo`, no `forwardRef`): the view * tree walk recognises it by identity. @@ -65,7 +67,7 @@ export function BugseeSecure(props: BugseeSecureProps): ReactElement { return; } try { - setOwnerRectangles(token, 0, [{ x, y, width, height }]); + setOwnerRectangles(token, 0, [{ x, y, width, height }], surfaceOf(view)); } catch (error) { failed(error); } @@ -101,3 +103,19 @@ export function BugseeSecure(props: BugseeSecureProps): ReactElement { return ; } + +/** + * The surface key native resolves for the React root holding `view`; the + * main surface when the view has no tag or native answers something that is + * not a key. Throws whatever the lookup throws: the caller treats that as a + * failed measurement and keeps the last rectangle. + */ +function surfaceOf(view: object): number { + const tag = (view as { __nativeTag?: unknown }).__nativeTag; + // Number.isFinite never coerces: a string or a missing tag is not finite. + if (!Number.isFinite(tag)) { + return MAIN_SURFACE; + } + const surface: unknown = NativeBugsee.secureSurfaceKey(tag as number); + return Number.isInteger(surface) ? (surface as number) : MAIN_SURFACE; +} diff --git a/packages/react-native/src/secure/__tests__/BugseeSecure.test.tsx b/packages/react-native/src/secure/__tests__/BugseeSecure.test.tsx index 78d7a668..dfe995ae 100644 --- a/packages/react-native/src/secure/__tests__/BugseeSecure.test.tsx +++ b/packages/react-native/src/secure/__tests__/BugseeSecure.test.tsx @@ -281,3 +281,93 @@ describe('', () => { expect(inner?.props.testID).toBe('inner'); }); }); + +// Fabric measureInWindow is relative to the React root that holds the view; +// a is its own root. The rectangle goes out under that root's surface +// key, which native resolves from the view's tag. +describe(' surfaces', () => { + function renderWithNode(element: ReactElement, node: Record): ReactTestRenderer { + let renderer: ReactTestRenderer | undefined; + act(() => { + renderer = create(element, { + createNodeMock: () => ({ measureInWindow, ...node }), + unstable_isConcurrent: true, + } as Parameters[1]); + }); + mounted.push(renderer as ReactTestRenderer); + return renderer as ReactTestRenderer; + } + + it('publishes on the surface native resolves for its tag', () => { + native.secureSurfaceKey.mockReturnValue(42); + + renderWithNode(, { __nativeTag: 77 }); + + expect(native.secureSurfaceKey).toHaveBeenCalledWith(77); + expect(native.setSecureRectanglesOnSurface).toHaveBeenLastCalledWith(0, 42, MEASURED); + expect(native.setSecureRectangles).not.toHaveBeenCalled(); + }); + + it('a tag that resolves to the main surface publishes there', () => { + native.secureSurfaceKey.mockReturnValue(0); + + renderWithNode(, { __nativeTag: 77 }); + + expect(native.setSecureRectangles).toHaveBeenLastCalledWith(0, MEASURED); + expect(native.setSecureRectanglesOnSurface).not.toHaveBeenCalled(); + }); + + it('removes its rectangle from that surface on unmount', () => { + native.secureSurfaceKey.mockReturnValue(42); + const renderer = renderWithNode(, { __nativeTag: 77 }); + + act(() => renderer.unmount()); + + expect(native.setSecureRectanglesOnSurface).toHaveBeenLastCalledWith(0, 42, []); + }); + + it.each([ + ['no tag', {}], + ['a NaN tag', { __nativeTag: Number.NaN }], + ['an infinite tag', { __nativeTag: Number.POSITIVE_INFINITY }], + ['a string tag', { __nativeTag: '77' }], + ])('%s asks nothing and publishes on the main surface', (_name, node) => { + renderWithNode(, node); + + expect(native.secureSurfaceKey).not.toHaveBeenCalled(); + expect(native.setSecureRectangles).toHaveBeenLastCalledWith(0, MEASURED); + }); + + it.each([ + ['NaN', Number.NaN], + ['a fraction', 1.5], + ['a non-number', '42'], + ['undefined', undefined], + ])('a surface key that is %s publishes on the main surface', (_name, key) => { + native.secureSurfaceKey.mockReturnValue(key as never); + + renderWithNode(, { __nativeTag: 77 }); + + expect(native.setSecureRectangles).toHaveBeenLastCalledWith(0, MEASURED); + expect(native.setSecureRectanglesOnSurface).not.toHaveBeenCalled(); + }); + + // Same as any failed measurement: the last good rectangle stands. + it('a throwing surface lookup keeps the last rectangle', () => { + native.secureSurfaceKey.mockReturnValue(42); + renderWithNode(, { __nativeTag: 77 }); + const failure = new Error('no root'); + native.secureSurfaceKey.mockImplementation(() => { + throw failure; + }); + native.setSecureRectanglesOnSurface.mockClear(); + measureInWindow.mockImplementation(measuresAt(10, 70, 30, 40)); + + tick(2); + + expect(native.setSecureRectanglesOnSurface).not.toHaveBeenCalled(); + expect(native.setSecureRectangles).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith('[Bugsee] BugseeSecure could not measure', failure); + }); +}); diff --git a/packages/react-native/src/secure/__tests__/registry.test.ts b/packages/react-native/src/secure/__tests__/registry.test.ts index 74b42b4a..31a5024c 100644 --- a/packages/react-native/src/secure/__tests__/registry.test.ts +++ b/packages/react-native/src/secure/__tests__/registry.test.ts @@ -205,3 +205,143 @@ describe('the secure-rectangle registry', () => { expect(native.setSecureRectangles).toHaveBeenLastCalledWith(0, [20, 40, 80, 120]); }); }); + +// Fabric measureInWindow is relative to the measured node's own React root, +// and a is its own root (an Android Dialog). Each surface's union is +// published on its own, so native can translate it by that surface's origin. +describe('the secure-rectangle registry, per surface', () => { + const MODAL = 42; + + // The Android `doMock` above outlives its own test; these are in points. + beforeEach(() => { + jest.resetModules(); + jest.doMock('react-native', () => ({ Platform: { OS: 'ios' } })); + native = require('../../__mocks__/native').native; + native.reset(); + registry = require('../registry'); + }); + + /** Every publish on display 0, in order, across both bridge methods. */ + function publishes(): unknown[][] { + const calls: { order: number; call: unknown[] }[] = []; + native.setSecureRectangles.mock.calls.forEach((call, i) => { + calls.push({ order: native.setSecureRectangles.mock.invocationCallOrder[i] as number, call: ['main', ...call] }); + }); + native.setSecureRectanglesOnSurface.mock.calls.forEach((call, i) => { + calls.push({ + order: native.setSecureRectanglesOnSurface.mock.invocationCallOrder[i] as number, + call: ['surface', ...call], + }); + }); + return calls.sort((a, b) => a.order - b.order).map(({ call }) => call); + } + + it('names the main surface 0, as native does', () => { + expect(registry.MAIN_SURFACE).toBe(0); + }); + + it('publishes another surface through setSecureRectanglesOnSurface, apart from the main one', () => { + registry.setOwnerRectangles('manual:0', 0, [A]); + registry.setOwnerRectangles({}, 0, [B], MODAL); + + expect(publishes()).toEqual([ + ['main', 0, A_FLAT], + ['surface', 0, MODAL, B_FLAT], + ]); + }); + + it('an explicit main surface is the same lane as the default', () => { + registry.setOwnerRectangles('manual:0', 0, [A]); + registry.setOwnerRectangles({}, 0, [B], registry.MAIN_SURFACE); + + expect(publishes()).toEqual([ + ['main', 0, A_FLAT], + ['main', 0, [...A_FLAT, ...B_FLAT]], + ]); + }); + + it('keeps each surface its own union', () => { + const first = {}; + const second = {}; + registry.setOwnerRectangles(first, 0, [A], MODAL); + registry.setOwnerRectangles(second, 0, [B], MODAL); + registry.setOwnerRectangles({}, 0, [A], 7); + + expect(publishes()).toEqual([ + ['surface', 0, MODAL, A_FLAT], + ['surface', 0, MODAL, [...A_FLAT, ...B_FLAT]], + ['surface', 0, 7, A_FLAT], + ]); + }); + + it('an unchanged surface union does not cross the bridge again', () => { + const component = {}; + registry.setOwnerRectangles(component, 0, [A], MODAL); + registry.setOwnerRectangles(component, 0, [{ ...A }], MODAL); + + expect(native.setSecureRectanglesOnSurface).toHaveBeenCalledTimes(1); + }); + + it('the first publish on a surface always crosses, even when empty', () => { + registry.setOwnerRectangles('manual:0', 0, []); + registry.setOwnerRectangles({}, 0, [], MODAL); + + expect(publishes()).toEqual([ + ['main', 0, []], + ['surface', 0, MODAL, []], + ]); + }); + + it('an owner that moves surface covers the new one before it uncovers the old', () => { + const component = {}; + registry.setOwnerRectangles(component, 0, [A]); + registry.setOwnerRectangles(component, 0, [A], MODAL); + + expect(publishes()).toEqual([ + ['main', 0, A_FLAT], + ['surface', 0, MODAL, A_FLAT], + ['main', 0, []], + ]); + }); + + it('an owner that moves display and surface leaves the old display\'s surface', () => { + const component = {}; + registry.setOwnerRectangles(component, 0, [A], MODAL); + registry.setOwnerRectangles(component, 1, [A]); + + expect(publishes()).toEqual([ + ['surface', 0, MODAL, A_FLAT], + ['main', 1, A_FLAT], + // Display 1 never had this surface: its first publish crosses, empty. + ['surface', 1, MODAL, []], + ['surface', 0, MODAL, []], + ]); + }); + + it('clearing an owner republishes only its own surface', () => { + const component = {}; + registry.setOwnerRectangles('manual:0', 0, [A]); + registry.setOwnerRectangles(component, 0, [B], MODAL); + + registry.clearOwner(component); + + expect(publishes()).toEqual([ + ['main', 0, A_FLAT], + ['surface', 0, MODAL, B_FLAT], + ['surface', 0, MODAL, []], + ]); + }); + + it('retries a surface publish the bridge threw on', () => { + native.setSecureRectanglesOnSurface.mockImplementationOnce(() => { + throw new Error('bridge down'); + }); + const component = {}; + expect(() => registry.setOwnerRectangles(component, 0, [A], MODAL)).toThrow('bridge down'); + + registry.setOwnerRectangles(component, 0, [A], MODAL); + + expect(native.setSecureRectanglesOnSurface).toHaveBeenCalledTimes(2); + expect(native.setSecureRectanglesOnSurface).toHaveBeenLastCalledWith(0, MODAL, A_FLAT); + }); +}); diff --git a/packages/react-native/src/secure/registry.ts b/packages/react-native/src/secure/registry.ts index 55a5fc06..4a4d5b97 100644 --- a/packages/react-native/src/secure/registry.ts +++ b/packages/react-native/src/secure/registry.ts @@ -13,8 +13,12 @@ import { secureRectangleScale } from './unit'; */ export type SecureOwner = object | `manual:${number}`; +/** The activity React root's surface; matches native `MAIN_SURFACE`. */ +export const MAIN_SURFACE = 0; + interface OwnerEntry { readonly display: number; + readonly surface: number; readonly rectangles: readonly SecureRectangle[]; } @@ -22,20 +26,24 @@ interface OwnerEntry { const owners = new Map(); /** - * What last reached the bridge, per display. A display missing here has never - * been published to by this JS runtime, so its first publish always crosses, - * even an empty one: the native store outlives a JS reload and may still hold - * a set this runtime knows nothing about. + * What last reached the bridge, per display and surface. A missing entry has + * never been published by this JS runtime, so its first publish always + * crosses, even an empty one: the native store outlives a JS reload and may + * still hold a set this runtime knows nothing about. */ -const published = new Map(); +const published = new Map(); + +function publishKey(display: number, surface: number): string { + return `${display}:${surface}`; +} function sameList(a: readonly number[], b: readonly number[]): boolean { return a.length === b.length && a.every((value, index) => value === b[index]); } /** - * Publishes the union of every owner's rectangles on `display`, if it differs - * from what was last published there. + * Publishes the union of every owner's rectangles on `display` for each + * surface that has (or had) owners there. * * Re-flattened every time rather than cached per owner: the pixel ratio is * read per publish (see `secureRectangleScale`), so a ratio change reaches the @@ -45,29 +53,37 @@ function sameList(a: readonly number[], b: readonly number[]): boolean { * already keep their version still for a no-op write. It saves a bridge hop * on every 100 ms re-measure that found nothing moved. */ -function publish(display: number): void { - const union: SecureRectangle[] = []; - for (const entry of owners.values()) { - if (entry.display === display) { - union.push(...entry.rectangles); +function publish(display: number, surfaces: ReadonlySet): void { + const scale = secureRectangleScale(); + for (const surface of surfaces) { + const union: SecureRectangle[] = []; + for (const entry of owners.values()) { + if (entry.display === display && entry.surface === surface) { + union.push(...entry.rectangles); + } } - } - const flat = flattenSecureRectangles(union, secureRectangleScale()); - const last = published.get(display); - if (last !== undefined && sameList(last, flat)) { - return; - } + const flat = flattenSecureRectangles(union, scale); + const key = publishKey(display, surface); + const last = published.get(key); + if (last !== undefined && sameList(last, flat)) { + continue; + } - NativeBugsee.setSecureRectangles(display, flat); - // Only after the call returned: a publish that threw was not published, and - // recording it would skip the retry as "unchanged". - published.set(display, flat); + if (surface === MAIN_SURFACE) { + NativeBugsee.setSecureRectangles(display, flat); + } else { + NativeBugsee.setSecureRectanglesOnSurface(display, surface, flat); + } + // Only after the call returned: a publish that threw was not published, and + // recording it would skip the retry as "unchanged". + published.set(key, flat); + } } /** - * Replaces `owner`'s rectangles with `rectangles` on `display` and publishes - * that display's union. + * Replaces `owner`'s rectangles with `rectangles` on `display`/`surface` and + * publishes that lane's union. * * Throws, changing nothing, if any rectangle is malformed. The owner's * previous rectangles stay published: a bad call never uncovers a region. @@ -76,6 +92,7 @@ export function setOwnerRectangles( owner: SecureOwner, display: number, rectangles: readonly SecureRectangle[], + surface: number = MAIN_SURFACE, ): void { // Validation first, before the entry changes. flattenSecureRectangles(rectangles, secureRectangleScale()); @@ -85,13 +102,18 @@ export function setOwnerRectangles( // must not change what a later publish for some other owner sends. owners.set(owner, { display, + surface, rectangles: rectangles.map(({ x, y, width, height }) => ({ x, y, width, height })), }); + const surfaces = new Set([surface]); + if (previous !== undefined) { + surfaces.add(previous.surface); + } // The new display is covered before the old one is uncovered. - publish(display); + publish(display, surfaces); if (previous !== undefined && previous.display !== display) { - publish(previous.display); + publish(previous.display, new Set([previous.surface])); } } @@ -102,5 +124,5 @@ export function clearOwner(owner: SecureOwner): void { return; } owners.delete(owner); - publish(previous.display); + publish(previous.display, new Set([previous.surface])); } diff --git a/packages/react-native/src/viewtree/__tests__/requests.test.ts b/packages/react-native/src/viewtree/__tests__/requests.test.ts index 3be9200f..2fc1b341 100644 --- a/packages/react-native/src/viewtree/__tests__/requests.test.ts +++ b/packages/react-native/src/viewtree/__tests__/requests.test.ts @@ -121,6 +121,91 @@ describe('a vh request', () => { }); }); +// A is its own React root: the walk asks native for the origin of the +// root that hosts each measured node, by that node's native tag. +describe('the walk env, per surface', () => { + const RENDERER_PROXY = 'react-native/Libraries/ReactNative/RendererProxy'; + + function envOf(): { + originForNativeTag: (tag: number) => { x: number; y: number } | null; + nativeTagOf: (fiber: FiberLike) => number | null; + } { + const { instance } = anchorUnderNewRoot(); + requests.registerAnchor(instance); + emit(); + return buildViewTree.mock.calls[0][1]; + } + + afterEach(() => { + jest.dontMock(RENDERER_PROXY); + }); + + it("resolves a tag's origin through secureSurfaceOrigin", () => { + native.secureSurfaceOrigin.mockReturnValue([40, 200.5]); + + expect(envOf().originForNativeTag(77)).toEqual({ x: 40, y: 200.5 }); + expect(native.secureSurfaceOrigin).toHaveBeenCalledWith(77); + }); + + it.each([ + ['empty', []], + ['one number', [40]], + ['a NaN x', [Number.NaN, 1]], + ['an infinite y', [1, Number.POSITIVE_INFINITY]], + ['not an array', { 0: 1, 1: 2, length: 2 }], + ['null', null], + ])('knows no origin when native answers %s', (_name, answer) => { + native.secureSurfaceOrigin.mockReturnValue(answer as never); + + expect(envOf().originForNativeTag(77)).toBeNull(); + }); + + it('knows no origin when native throws', () => { + native.secureSurfaceOrigin.mockImplementation(() => { + throw new Error('no root'); + }); + + expect(envOf().originForNativeTag(77)).toBeNull(); + }); + + it("reads a host fiber's tag off its public instance", () => { + const fiber = makeFiber(); + const seen: unknown[] = []; + jest.doMock(RENDERER_PROXY, () => ({ + getPublicInstanceFromInternalInstanceHandle: (handle: unknown) => { + seen.push(handle); + return { __nativeTag: 77 }; + }, + })); + + expect(envOf().nativeTagOf(fiber)).toBe(77); + expect(seen).toEqual([fiber]); + }); + + it.each([ + ['no public instance', null], + ['no tag', {}], + ['a NaN tag', { __nativeTag: Number.NaN }], + ['a string tag', { __nativeTag: '77' }], + ])('has no tag for %s', (_name, instance) => { + jest.doMock(RENDERER_PROXY, () => ({ + getPublicInstanceFromInternalInstanceHandle: () => instance, + })); + + expect(envOf().nativeTagOf(makeFiber())).toBeNull(); + }); + + it('has no tag when the renderer throws', () => { + jest.doMock(RENDERER_PROXY, () => ({ + getPublicInstanceFromInternalInstanceHandle: () => { + throw new Error('unmounted'); + }, + })); + + expect(envOf().nativeTagOf(makeFiber())).toBeNull(); + }); +}); + describe('a walk that finds nothing', () => { // `buildViewTree` returns `null` whenever no surface emits a node: nothing // measurable yet, everything under a hidden Offscreen, or every root's @@ -494,6 +579,7 @@ describe('the real walk env', () => { function mockMeasuring(): void { jest.doMock(RENDERER_PROXY_PATH, () => ({ getPublicInstanceFromInternalInstanceHandle: (fiber: FiberLike) => ({ + __nativeTag: (fiber.stateNode as { tag?: number } | null)?.tag, measureInWindow: (callback: (x: number, y: number, width: number, height: number) => void) => { const rect = (fiber.stateNode as { rect?: { x: number; y: number; width: number; height: number } } | null)?.rect; if (rect) { @@ -521,11 +607,16 @@ describe('the real walk env', () => { } /** Builds `HostRoot -> WrapMarker -> [appHost, BugseeSecure -> secureHost, anchor]` and registers a real anchor instance for it, returning the parsed 'vh' reply payload. */ - function runRealWalk(platform: 'ios' | 'android', origin: { x: number; y: number } = { x: 0, y: 0 }): Node { + function runRealWalk( + platform: 'ios' | 'android', + origin: { x: number; y: number } = { x: 0, y: 0 }, + arrange: () => void = () => undefined, + ): Node { jest.unmock('../walk'); jest.resetModules(); ({ native } = require('../../__mocks__/native')); native.reset(); + arrange(); jest.doMock('react-native', () => ({ Platform: { OS: platform }, PixelRatio: { get: () => 3 }, @@ -542,7 +633,7 @@ describe('the real walk env', () => { const appHost = makeFiber({ type: 'RootView', memoizedProps: { testID: 'app-root' }, - stateNode: { rect: { x: 1, y: 2, width: 10, height: 20 } }, + stateNode: { rect: { x: 1, y: 2, width: 10, height: 20 }, tag: 11 }, }); const secureHost = makeFiber({ type: 'SecretView', @@ -655,4 +746,15 @@ describe('the real walk env', () => { // it is already in display px, not a point value that itself needs 3x. expect(appNode?.bounds).toEqual([103, 206, 30, 60]); }); + + it('places a node by the origin native resolves for its own React root', () => { + const tree = runRealWalk('android', { x: 100, y: 200 }, () => { + native.secureSurfaceOrigin.mockImplementation((tag: number) => (tag === 11 ? [40, 60] : [])); + }); + const appNode = flatten(tree).find((n) => n.class_name === 'RootView'); + + // Scaled by 3 as above, then moved by (40, 60), not the request's (100, 200). + expect(appNode?.bounds).toEqual([43, 66, 30, 60]); + expect(native.secureSurfaceOrigin).toHaveBeenCalledWith(11); + }); }); diff --git a/packages/react-native/src/viewtree/__tests__/walk.test.ts b/packages/react-native/src/viewtree/__tests__/walk.test.ts index 509c004b..4b99d98f 100644 --- a/packages/react-native/src/viewtree/__tests__/walk.test.ts +++ b/packages/react-native/src/viewtree/__tests__/walk.test.ts @@ -779,6 +779,77 @@ describe('buildViewTree', () => { expect(node?.bounds).toEqual([11.13, 22.67, 5.56, 6.45]); }); + // A is its own React root (an Android Dialog): measureInWindow is + // relative to it, so its nodes take that root's origin, not the request's. + describe('a node on another React surface', () => { + const tagged = (tag: number) => (fiber: FiberLike): number | null => + (fiber.memoizedProps as { tag?: number } | null)?.tag === tag ? tag : null; + const sheet = (): FiberSpec => host('Sheet', { x: 10, y: 20, width: 5, height: 5 }, [], { tag: 77 }); + + it('Android: takes the origin native resolves for its tag', () => { + const originForNativeTag = jest.fn((tag: number) => (tag === 77 ? { x: 40, y: 200 } : null)); + const tree = buildViewTree( + [fiberRoot(fragment([host('Main', { x: 10, y: 20, width: 5, height: 5 }), sheet()]))], + makeEnv({ platform: 'android', scale: 2, originX: 0, originY: 63, nativeTagOf: tagged(77), originForNativeTag }), + ); + + const [main, modal] = tree?.subitems?.[0]?.subitems ?? []; + expect(main?.bounds).toEqual([20, 103, 10, 10]); + expect(modal?.bounds).toEqual([60, 240, 10, 10]); + expect(originForNativeTag).toHaveBeenCalledTimes(1); + expect(originForNativeTag).toHaveBeenCalledWith(77); + }); + + it('iOS: takes the origin native resolves for its tag', () => { + const tree = buildViewTree( + [fiberRoot(sheet())], + makeEnv({ platform: 'ios', originX: 1, originY: 2, nativeTagOf: tagged(77), originForNativeTag: () => ({ x: 3.5, y: 4 }) }), + ); + + expect(tree?.subitems?.[0]?.subitems?.[0]?.bounds).toEqual([13.5, 24, 5, 5]); + }); + + it('falls back to the request origin when native knows no origin for the tag', () => { + const tree = buildViewTree( + [fiberRoot(sheet())], + makeEnv({ platform: 'android', scale: 1, originX: 7, originY: 9, nativeTagOf: tagged(77), originForNativeTag: () => null }), + ); + + expect(tree?.subitems?.[0]?.subitems?.[0]?.bounds).toEqual([17, 29, 5, 5]); + }); + + it('asks nothing for a node without a tag', () => { + const originForNativeTag = jest.fn(() => ({ x: 40, y: 200 })); + const tree = buildViewTree( + [fiberRoot(sheet())], + makeEnv({ platform: 'android', scale: 1, originX: 7, originY: 9, nativeTagOf: () => null, originForNativeTag }), + ); + + expect(tree?.subitems?.[0]?.subitems?.[0]?.bounds).toEqual([17, 29, 5, 5]); + expect(originForNativeTag).not.toHaveBeenCalled(); + }); + + it('uses the request origin when the env has no tag reader', () => { + const originForNativeTag = jest.fn(() => ({ x: 40, y: 200 })); + const tree = buildViewTree( + [fiberRoot(sheet())], + makeEnv({ platform: 'android', scale: 1, originX: 7, originY: 9, originForNativeTag }), + ); + + expect(tree?.subitems?.[0]?.subitems?.[0]?.bounds).toEqual([17, 29, 5, 5]); + expect(originForNativeTag).not.toHaveBeenCalled(); + }); + + it('uses the request origin when the env has no origin resolver', () => { + const tree = buildViewTree( + [fiberRoot(sheet())], + makeEnv({ platform: 'android', scale: 1, originX: 7, originY: 9, nativeTagOf: tagged(77) }), + ); + + expect(tree?.subitems?.[0]?.subitems?.[0]?.bounds).toEqual([17, 29, 5, 5]); + }); + }); + it('stops at exactly VH_MAX_NODES nodes emitted in total โ€” root and surface reserved up front, not overshot', () => { const children = Array.from({ length: VH_MAX_NODES + 5 }, (_, i) => host(`Child${i}`, RECT)); const tree = buildViewTree([fiberRoot(fragment(children))], makeEnv()); diff --git a/packages/react-native/src/viewtree/requests.ts b/packages/react-native/src/viewtree/requests.ts index f745053d..264d93f4 100644 --- a/packages/react-native/src/viewtree/requests.ts +++ b/packages/react-native/src/viewtree/requests.ts @@ -202,10 +202,41 @@ function buildEnv(event: DataRequestEvent): WalkEnv { scale: secureRectangleScale(), originX: event.originX, originY: event.originY, + nativeTagOf: nativeTagOfFiber, + originForNativeTag: (tag) => { + try { + const origin: unknown = NativeBugsee.secureSurfaceOrigin(tag); + // Number.isFinite never coerces, so a missing element fails it too. + if (Array.isArray(origin) && Number.isFinite(origin[0]) && Number.isFinite(origin[1])) { + return { x: origin[0] as number, y: origin[1] as number }; + } + } catch { + // Fall back to the activity / main origin from the request. + } + return null; + }, now: monotonicNow, }; } +/** + * Fabric public instances carry `__nativeTag`. Used to resolve the hosting + * React root's display origin for a measured host (Modal vs activity). + */ +function nativeTagOfFiber(fiber: FiberLike): number | null { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports -- same lazy require as measureHostFiber. + const rendererProxy = require('react-native/Libraries/ReactNative/RendererProxy') as { + getPublicInstanceFromInternalInstanceHandle: (fiber: unknown) => unknown; + }; + const publicInstance = rendererProxy.getPublicInstanceFromInternalInstanceHandle(fiber); + const tag = (publicInstance as { __nativeTag?: unknown } | null)?.__nativeTag; + return Number.isFinite(tag) ? (tag as number) : null; + } catch { + return null; + } +} + /** * The JSON text to reply with for a `'vh'` request that has at least one root * to walk, or `null` for every outcome that isn't a real tree. diff --git a/packages/react-native/src/viewtree/walk.ts b/packages/react-native/src/viewtree/walk.ts index 76f28ff2..7b779417 100644 --- a/packages/react-native/src/viewtree/walk.ts +++ b/packages/react-native/src/viewtree/walk.ts @@ -66,6 +66,14 @@ export interface WalkEnv { scale: number; originX: number; originY: number; + /** + * Display origin of the React root that hosts `nativeTag`, when known. + * A ``'s dialog root must not inherit the activity root's origin. + * Falls back to {@link originX}/{@link originY} when absent or null. + */ + originForNativeTag?: (nativeTag: number) => { x: number; y: number } | null; + /** Native tag of a host fiber's public instance, when measurable. */ + nativeTagOf?: (fiber: FiberLike) => number | null; /** ms; the budget is measured from this function's first call. */ now(): number; } @@ -121,26 +129,40 @@ function round2(value: number): number { return Math.round(value * 100) / 100; } -function androidBounds(rect: WindowRect, env: WalkEnv): Bounds { +function androidBounds(rect: WindowRect, env: WalkEnv, originX: number, originY: number): Bounds { return [ - Math.round(rect.x * env.scale) + env.originX, - Math.round(rect.y * env.scale) + env.originY, + Math.round(rect.x * env.scale) + originX, + Math.round(rect.y * env.scale) + originY, Math.round(rect.width * env.scale), Math.round(rect.height * env.scale), ]; } -function iosBounds(rect: WindowRect, env: WalkEnv): Bounds { +function iosBounds(rect: WindowRect, env: WalkEnv, originX: number, originY: number): Bounds { return [ - round2(rect.x + env.originX), - round2(rect.y + env.originY), + round2(rect.x + originX), + round2(rect.y + originY), round2(rect.width), round2(rect.height), ]; } -function toBounds(rect: WindowRect, env: WalkEnv): Bounds { - return env.platform === 'android' ? androidBounds(rect, env) : iosBounds(rect, env); +function originForFiber(fiber: FiberLike, env: WalkEnv): { x: number; y: number } { + const tag = env.nativeTagOf?.(fiber) ?? null; + if (tag != null && env.originForNativeTag) { + const resolved = env.originForNativeTag(tag); + if (resolved != null) { + return resolved; + } + } + return { x: env.originX, y: env.originY }; +} + +function toBounds(rect: WindowRect, env: WalkEnv, fiber: FiberLike): Bounds { + const origin = originForFiber(fiber, env); + return env.platform === 'android' + ? androidBounds(rect, env, origin.x, origin.y) + : iosBounds(rect, env, origin.x, origin.y); } function unionBounds(boxes: readonly Bounds[], env: WalkEnv): Bounds { @@ -635,7 +657,7 @@ function runWalk(rootFiber: FiberLike, ctx: Ctx, outerFrame: Frame): void { const child = linkOf(fiber.child); const atDepthLimit = child !== null && depthRemaining <= 1; const depthCut = atDepthLimit && hasVisibleContentChild(child as FiberLike); - const bounds = toBounds(rect, ctx.env); + const bounds = toBounds(rect, ctx.env, fiber); const tagOpts = secureHere ? {} : tagOptions(fiber); const className = safeHostClassName(fiber); From ab84802d02ebdf5c270a9f135529122d57f9a6db Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Sun, 4 Oct 2026 23:16:07 +0500 Subject: [PATCH 04/19] test(e2e): cover inside a on both surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scenario secure-modal mounts a white on the activity root and another inside a transparent , logs both measureInWindow rectangles and uploads. secure-modal.test.ts asserts both regions are black in the report's screenshot while a control below the main one stays bright, and on Android that the served set covers the main rectangle (from JS, scaled and moved by the activity origin) and the sheet (from uiautomator) within 2 px. Ported from feat/phase-6-modal (9ec12a2); main already had E2E_METRO_HOST and its own metro watchFolders, so neither is re-added. The unused MAIN_LABEL constant is dropped for lint. Device: WOD_LX1 edge-to-edge on and off, iPhone 17 Pro simulator: 1/1 each, mainInner and sheetInner luma 0. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --- examples/bare/App.tsx | 3 +- examples/bare/e2e/secure-modal.test.ts | 220 +++++++++++++++++++++++++ examples/bare/scenarios/privacy.tsx | 77 +++++++++ 3 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 examples/bare/e2e/secure-modal.test.ts diff --git a/examples/bare/App.tsx b/examples/bare/App.tsx index 5c5aac36..0ce8ca06 100644 --- a/examples/bare/App.tsx +++ b/examples/bare/App.tsx @@ -15,7 +15,8 @@ * is scenarios/secure.tsx, `attributes`/`attributes-persist` are * scenarios/attributes.ts, `e2e-native-smoke` / `native-crash-*` are * scenarios/native.ts, `blackout`, `blackout-prelaunch`, - * `secure-component` and `view-tree` are scenarios/privacy.tsx, and + * `secure-component`, `secure-modal` and `view-tree` are + * scenarios/privacy.tsx, and * `exc-*` are scenarios/exceptions.tsx, `rp-*` are * scenarios/reporting.ts, `log-filter` is scenarios/log-filter.ts, * `breadcrumb-filter` is scenarios/breadcrumb-filter.ts, diff --git a/examples/bare/e2e/secure-modal.test.ts b/examples/bare/e2e/secure-modal.test.ts new file mode 100644 index 00000000..178b504e --- /dev/null +++ b/examples/bare/e2e/secure-modal.test.ts @@ -0,0 +1,220 @@ +/** + * Phase 6 Modal follow-up: a `` on the activity root and one + * inside a `` must both cover their on-screen controls. Fabric + * measureInWindow is surface-relative; each rectangle is translated by that + * surface's display origin at pull time. + * + * Scenario `secure-modal` (scenarios/privacy.tsx). Android: WOD_LX1 with + * `E2E_EDGE_TO_EDGE` naming the installed build. iOS: simulator or XS + * (`E2E_IOS_TARGET`), ground truth from the scenario's measureInWindow logs. + */ +import { type PulledBundle, airplane, removePulledBundles } from './bundles'; +import { + ON_IOS, + type Run, + TARGET_NAME, + awaitBundles, + clearBundles, + describeDevice, + escape, + must, + report, + startDeviceLog, + startRun, + stopApp, + stopDeviceLog, +} from './harness'; +import { LUMA_BRIGHT_MIN, LUMA_DARK_MAX } from './media'; +import { type DeviceLog, type LogLine, adb, resetScenario } from './scenario'; +import { + type Media, + type Rect, + assertMedia, + boundsIn, + bundleBySummary, + displaySize, + markerScreen, + moved, + screenshotLumas, + servedOf, + uiDump, +} from './screen'; + +jest.setTimeout(4 * 60_000); + +const SHEET_LABEL = 'bugsee-secure-modal-sheet'; +const EDGE_TO_EDGE = process.env.E2E_EDGE_TO_EDGE; +const SERVED = /BugseeRN\s*:\s*secure (published|origin) .*served=/; + +describeDevice(` in Modal on ${TARGET_NAME}`, () => { + let log: DeviceLog; + let run: Run; + let nonce: string; + let display: { width: number; height: number }; + let mainBounds: Rect; + let sheetBounds: Rect; + let servedLine: LogLine; + let bundle: PulledBundle; + let media: Media; + + const marker = async (what: string, from: number, timeoutMs = 45_000): Promise => + must( + await log.waitFor(new RegExp(`BUGSEE_E2E secure-modal ${escape(what)}.* nonce=${nonce}`), timeoutMs, from), + `the secure-modal scenario's "${what}" marker`, + run.start, + ); + + beforeAll(async () => { + if (!ON_IOS && EDGE_TO_EDGE !== 'true' && EDGE_TO_EDGE !== 'false') { + throw new Error( + `E2E_EDGE_TO_EDGE must state how the installed build was made, "true" or "false"; got ${JSON.stringify(EDGE_TO_EDGE)}`, + ); + } + log = await startDeviceLog('6.modal', '6.modal'); + if (!ON_IOS) { + await adb('shell', 'setprop', 'log.tag.BugseeRN', 'DEBUG'); + await airplane(true); + display = await displaySize(); + } + + await clearBundles(); + run = await startRun('secure-modal'); + nonce = run.scenario.nonce; + report('banner', run.banner.text.trim()); + + const rect = await marker('rect phase=both', run.launched.index); + await marker('uploaded', rect.index); + const still = await marker('still', rect.index); + + const main = /main=(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)/.exec(rect.text); + const sheet = /sheet=(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)/.exec(rect.text); + if (main === null || sheet === null) { + throw new Error(`could not parse main/sheet from ${rect.text}`); + } + const ratio = Number(/\bratio=([\d.]+)/.exec(rect.text)?.[1] ?? (ON_IOS ? '1' : 'NaN')); + if (!Number.isFinite(ratio) || ratio <= 0) { + throw new Error(`could not parse PixelRatio from ${rect.text}`); + } + + if (ON_IOS) { + // Simulator window points == screen points (Task 6.9 rule). + display = markerScreen(rect); + mainBounds = { + left: Number(main[1]), + top: Number(main[2]), + right: Number(main[1]) + Number(main[3]), + bottom: Number(main[2]) + Number(main[4]), + }; + sheetBounds = { + left: Number(sheet[1]), + top: Number(sheet[2]), + right: Number(sheet[1]) + Number(sheet[3]), + bottom: Number(sheet[2]) + Number(sheet[4]), + }; + report('JS rectangles (points)', { main: mainBounds, sheet: sheetBounds }); + } else { + // A focused Android Dialog's accessibility dump does not include the + // activity-root control behind it. Sheet bounds come from uiautomator; + // main bounds from the twin's measureInWindow, scaled like the registry + // (and shifted by the activity origin when edge-to-edge is off). + const upTo = log.mark(); + servedLine = must(log.all(SERVED, run.start, upTo).pop(), 'a served line before the dump', run.start); + const originLine = log.all(/BugseeRN\s*:\s*secure origin .*surface=0\b/, run.start, upTo).pop() + ?? log.all(/BugseeRN\s*:\s*secure origin /, run.start, upTo).pop(); + const origin = /origin=(-?\d+),(-?\d+)/.exec(originLine?.text ?? ''); + const ox = origin ? Number(origin[1]) : 0; + const oy = origin ? Number(origin[2]) : 0; + mainBounds = { + left: Math.round(Number(main[1]) * ratio) + ox, + top: Math.round(Number(main[2]) * ratio) + oy, + right: Math.round((Number(main[1]) + Number(main[3])) * ratio) + ox, + bottom: Math.round((Number(main[2]) + Number(main[4])) * ratio) + oy, + }; + const { xml } = await uiDump(); + sheetBounds = boundsIn(xml, SHEET_LABEL); + report('bounds', { + mainFromJs: mainBounds, + sheetFromDump: sheetBounds, + origin: { ox, oy }, + ratio, + stillBeforeDump: still.deviceMs, + }); + report('served', servedLine.text.trim()); + } + + report('display', display); + const bundles = await awaitBundles(1); + bundle = bundleBySummary(bundles, `secure-modal-${nonce}`); + media = await assertMedia(bundle); + report('media', { + screenshots: media.screenshots.length, + codecs: media.screenshotCodecs, + video: media.videoCodec, + }); + }); + + afterAll(async () => { + try { + await stopApp(); + await clearBundles().catch((error: unknown) => report('cleanup clear failed', String(error))); + } finally { + try { + if (!ON_IOS) { + await airplane(false); + } + } finally { + if (!ON_IOS) { + await adb('shell', 'setprop', 'log.tag.BugseeRN', "''").catch(() => {}); + } + const { removed, kept } = removePulledBundles(); + report('pulled bundle roots', { removed: removed.length, kept }); + stopDeviceLog(log); + resetScenario(); + } + } + }); + + it('both the main and modal secure regions are masked', async () => { + const mainInner = await screenshotLumas(media.screenshots, mainBounds, display.width, 0.6); + const sheetInner = await screenshotLumas(media.screenshots, sheetBounds, display.width, 0.6); + const control: Rect = moved(mainBounds, (mainBounds.bottom - mainBounds.top) + 40); + const controlLumas = await screenshotLumas(media.screenshots, control, display.width); + report('lumas', { mainInner, sheetInner, control, controlLumas }); + + for (const luma of mainInner) { + expect(luma).toBeLessThanOrEqual(LUMA_DARK_MAX); + } + for (const luma of sheetInner) { + expect(luma).toBeLessThanOrEqual(LUMA_DARK_MAX); + } + for (const luma of controlLumas) { + expect(luma).toBeGreaterThanOrEqual(LUMA_BRIGHT_MIN); + } + + if (ON_IOS) { + return; + } + + const served = servedOf(servedLine); + expect(served.count).toBeGreaterThanOrEqual(2); + report('served vs on screen', { + edgeToEdge: EDGE_TO_EDGE, + served: served.rects, + main: mainBounds, + sheet: sheetBounds, + }); + + const covers = (rect: { left: number; top: number; right: number; bottom: number }, onScreen: Rect): boolean => + rect.left <= onScreen.left && + rect.top <= onScreen.top && + rect.right >= onScreen.right && + rect.bottom >= onScreen.bottom && + onScreen.left - rect.left <= 2 && + onScreen.top - rect.top <= 2 && + rect.right - onScreen.right <= 2 && + rect.bottom - onScreen.bottom <= 2; + + expect(served.rects.some(r => covers(r, mainBounds))).toBe(true); + expect(served.rects.some(r => covers(r, sheetBounds))).toBe(true); + }); +}); diff --git a/examples/bare/scenarios/privacy.tsx b/examples/bare/scenarios/privacy.tsx index d7e40f8f..264b5b88 100644 --- a/examples/bare/scenarios/privacy.tsx +++ b/examples/bare/scenarios/privacy.tsx @@ -39,6 +39,7 @@ import { Animated, Dimensions, Easing, + Modal, PixelRatio, ScrollView, StyleSheet, @@ -52,6 +53,7 @@ export const PRIVACY_SCENARIOS = [ 'blackout', 'blackout-prelaunch', 'secure-component', + 'secure-modal', 'view-tree', ] as const; @@ -69,6 +71,8 @@ export const HOLD_MS = 8_000; /** The accessibility labels the tests find the probes by (uiautomator). */ export const SECURE_COMPONENT_LABEL = 'bugsee-secure-component'; export const SECURE_WITNESS_LABEL = 'bugsee-secure-witness'; +export const SECURE_MODAL_MAIN_LABEL = 'bugsee-secure-modal-main'; +export const SECURE_MODAL_SHEET_LABEL = 'bugsee-secure-modal-sheet'; export const VH_OPEN_LABEL = 'vh-open-probe'; function mark(message: string): void { @@ -309,6 +313,68 @@ function SecureComponentProbe({ nonce, setMoving }: ProbeProps) { ); } +const MODAL_MAIN_RECT = { position: 'absolute', top: 120, left: 40, width: 160, height: 80 } as const; +/** Same absolute box for the secure view and its measure twin (must overlap). */ +const MODAL_SHEET_RECT = { position: 'absolute', top: 80, left: 80, width: 200, height: 100 } as const; + +/** + * secure-modal: a white `` on the activity root and another + * inside a `` (Android Dialog / iOS presented VC). Both must land in + * display space for their own surface โ€” the sheet must not be translated by + * the activity root's origin alone. + */ +function SecureModalProbe({ nonce, setMoving }: ProbeProps) { + const mainTwin = useRef(null); + const sheetTwin = useRef(null); + + useLayoutEffect(() => { + mark(`secure-modal mounted t=${Date.now()} nonce=${nonce}`); + }, [nonce]); + + useEffect(() => { + (async () => { + await sleep(2000); + const main = await measure(mainTwin.current); + const sheet = await measure(sheetTwin.current); + mark( + `secure-modal rect phase=both main=${main.x},${main.y},${main.w},${main.h} ` + + `sheet=${sheet.x},${sheet.y},${sheet.w},${sheet.h} ${screenLine()} nonce=${nonce}`, + ); + await sleep(1500); + Bugsee.upload(`secure-modal-${nonce}`, ''); + mark(`secure-modal uploaded t=${Date.now()} nonce=${nonce}`); + await sleep(SETTLE_MS); + setMoving(false); + mark(`secure-modal still t=${Date.now()} nonce=${nonce}`); + await sleep(HOLD_MS); + setMoving(true); + })().catch((error: unknown) => { + mark(`secure-modal threw ${String(error)} nonce=${nonce}`); + }); + }, [nonce, setMoving]); + + return ( + <> + + + + + + + + + + ); +} + /** * view-tree: what the managed tree must describe, and what it must never * carry (the text, the typed value, and every id under the secure boundary). @@ -387,6 +453,9 @@ export function PrivacyStage({ {launched && scenario === 'secure-component' && ( )} + {launched && scenario === 'secure-modal' && ( + + )} {launched && scenario === 'view-tree' && ( )} @@ -399,4 +468,12 @@ const styles = StyleSheet.create({ stage: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: '#FFFFFF' }, square: { position: 'absolute', left: 0, width: SQUARE, height: SQUARE, backgroundColor: '#000000' }, witness: { position: 'absolute', left: 20, width: 80, height: 80, backgroundColor: '#000000' }, + modalSheet: { + flex: 1, + marginTop: 200, + marginHorizontal: 24, + backgroundColor: '#FFFFFF', + borderTopLeftRadius: 12, + borderTopRightRadius: 12, + }, }); From 99ae3552bbcc3c316bb6da6b9fa5616b41de93b7 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Mon, 5 Oct 2026 11:39:07 +0500 Subject: [PATCH 05/19] fix(privacy): place each Modal by its own root, keyed off the fiber tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fix round 1 (C1, C2, I1, I2, M1, M3, M4, M5). C1, Android: the origin of a Modal's DialogRootViewGroup is its locationOnScreen alone. Only a ReactRoot (the activity root) has its viewport offset applied by Fabric; the dialog root is a RootView that is not one, and the modal host node's transform is the identity. ReactRootOriginTracker.surfaceOrigin makes the choice, and the JVM dialog test now feeds a -51 viewport (a translucent status bar) and expects locationOnScreen. C2: no native call per node, and none on a timer. JS reads a view's surface off the fiber tree: the React tag of the nearest RCTModalHostView host above it, or the main surface (src/secure/surface.ts). reads it once per mount and publishes {surface, rects}. The vh walk carries an origin down the tree and asks secureSurfaceOrigin once per Modal per walk. The spec loses secureSurfaceKey. Android watches a Modal's dialog root when JS first publishes on it (found from the host's first child), retries on every refresh until the root exists, and answers secureSurfaceOrigin from that cache. The activity root is the main surface and is watched once (M5). I2, fail closed: a surface other than the main one whose origin has not been read serves one full-display rectangle on both platforms. A Modal whose host tag cannot be read publishes on surface -1, never the main one. Nothing falls back to the main surface on a timeout. I1, iOS: each Modal has its own lane. Its origin is the presented view controller's view in window coordinates plus the window's frame.origin, read at pull time, so a pageSheet is placed inside the window. Per lane the store takes #30's semantics: a CGPoint origin, edges rounded outward and saturating, and re-recording the same origin costs nothing (M3). The comments M4 found deleted are back. M1: an empty Modal lane is dropped when its dialog root detaches (Android) or its host is gone (iOS). Tests: Jest 2093 passed (surface.test.ts, the BugseeSecure, registry, walk and requests surface cases); JVM 287 root and 317 example-Gradle; BugseeRNSupport XCTests 246. Mutation: applying the activity formula to every root fails aDialogRootsOriginIsItsLocationOnScreenAlone and aWatchedSurfacesOriginMovesOnlyItsRectangles; on the WOD_LX1 (edge-to-edge off) the same build fails secure-modal-translucent: 8128 cyan pixels of the Modal's secure view in the report, served sheet 611-811 against 560-760 on screen. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --- .../com/bugsee/reactnative/BugseeModule.java | 112 ++----- .../reactnative/ReactRootOriginTracker.java | 277 ++++++++++++++---- .../reactnative/SecureRectangleStore.java | 85 +++++- .../ReactRootOriginTrackerTest.java | 183 +++++++++++- .../reactnative/SecureRectangleStoreTest.java | 62 ++++ packages/react-native/ios/BugseeModule.mm | 141 ++++++--- .../BugseeRNSupport/BGSRNSecureRectangles.m | 206 ++++++++----- .../include/BGSRNSecureRectangles.h | 52 +++- .../BGSRNSecureRectanglesTests.m | 107 ++++++- packages/react-native/src/NativeBugsee.ts | 27 +- packages/react-native/src/__mocks__/native.ts | 4 +- .../react-native/src/secure/BugseeSecure.tsx | 37 +-- .../secure/__tests__/BugseeSecure.test.tsx | 135 ++++++--- .../src/secure/__tests__/surface.test.ts | 145 +++++++++ packages/react-native/src/secure/registry.ts | 4 +- packages/react-native/src/secure/surface.ts | 83 ++++++ .../src/viewtree/__tests__/requests.test.ts | 49 +++- .../src/viewtree/__tests__/walk.test.ts | 142 ++++++--- .../react-native/src/viewtree/requests.ts | 25 +- packages/react-native/src/viewtree/walk.ts | 100 +++++-- 20 files changed, 1498 insertions(+), 478 deletions(-) create mode 100644 packages/react-native/src/secure/__tests__/surface.test.ts create mode 100644 packages/react-native/src/secure/surface.ts diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java index 29c4438f..0c435458 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java @@ -3,10 +3,8 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import android.graphics.Point; import android.util.Base64; import android.util.Log; -import android.view.View; import com.bugsee.library.Bugsee; import com.bugsee.library.contracts.exchange.Breadcrumb; @@ -25,14 +23,10 @@ import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.ReadableMapKeySetIterator; import com.facebook.react.bridge.ReadableType; -import com.facebook.react.bridge.UIManager; -import com.facebook.react.bridge.UiThreadUtil; import com.facebook.react.bridge.WritableArray; +import com.facebook.react.bridge.UiThreadUtil; import com.facebook.react.bridge.WritableMap; import com.facebook.react.module.annotations.ReactModule; -import com.facebook.react.uimanager.RootView; -import com.facebook.react.uimanager.RootViewUtil; -import com.facebook.react.uimanager.UIManagerHelper; import java.io.Serializable; import java.util.ArrayList; @@ -285,6 +279,9 @@ private void publishSecure(final double display, final int surface, final Readab return; } originTracker.refreshSoon(); + // A 's first publish: find and watch its dialog root. + // Until then its rectangles fail closed in the store. + originTracker.watchSurface(surface); if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "secure published display=" + (int) display + " surface=" + surface @@ -297,18 +294,21 @@ private void publishSecure(final double display, final int surface, final Readab } } + /** + * The display origin {@code [x, y]} of the {@code } whose host has + * React tag {@code surface}, for the {@code vh} walk, which asks once per + * {@code } per walk. Answered from the tracker's cache when the + * surface is watched; otherwise read on the UI thread, waiting at most + * {@link #SURFACE_ORIGIN_WAIT_MS}. Empty when unknown: the walk then + * keeps the request's origin for that subtree. + */ @Override - public double secureSurfaceKey(final double viewTag) { - return resolveOnUiThread( - SecureRectangleStore.MAIN_SURFACE, - () -> surfaceKeyForViewTag((int) Math.round(viewTag))); - } - - @Override - public WritableArray secureSurfaceOrigin(final double viewTag) { - final int[] origin = resolveOnUiThread( - null, - () -> surfaceOriginForViewTag((int) Math.round(viewTag))); + public WritableArray secureSurfaceOrigin(final double surface) { + final int key = (int) Math.round(surface); + int[] origin = originTracker.cachedSurfaceOrigin(key); + if (origin == null) { + origin = readSurfaceOriginOnUiThread(key); + } final WritableArray out = Arguments.createArray(); if (origin != null) { out.pushDouble(origin[0]); @@ -317,85 +317,27 @@ public WritableArray secureSurfaceOrigin(final double viewTag) { return out; } - @Nullable - private int[] surfaceOriginForViewTag(final int viewTag) { - final View rootView = rootViewForTag(viewTag); - if (rootView == null) { - return null; - } - final int[] onScreen = new int[2]; - rootView.getLocationOnScreen(onScreen); - final Point viewport = RootViewUtil.getViewportOffset(rootView); - return SecureRectangleStore.displayOrigin(onScreen, viewport.x, viewport.y); - } - - private int surfaceKeyForViewTag(final int viewTag) { - final View rootView = rootViewForTag(viewTag); - if (rootView == null) { - return SecureRectangleStore.MAIN_SURFACE; - } - final ReactRootOriginTracker.RootHandle handle = ReactRootOriginTracker.handleFor(rootView); - if (handle == null) { - return SecureRectangleStore.MAIN_SURFACE; - } - originTracker.watchNow(handle); - return handle.surfaceKey(); - } + /** Well inside the walk's own 250 ms budget. */ + private static final long SURFACE_ORIGIN_WAIT_MS = 100; @Nullable - private View rootViewForTag(final int viewTag) { - final ReactApplicationContext context = getReactApplicationContext(); - if (context == null || !context.hasActiveReactInstance()) { - return null; - } - try { - final UIManager uiManager = UIManagerHelper.getUIManagerForReactTag(context, viewTag); - if (uiManager == null) { - return null; - } - final View view = uiManager.resolveView(viewTag); - if (view == null) { - return null; - } - final RootView root = RootViewUtil.getRootView(view); - return root instanceof View ? (View) root : null; - } catch (Throwable t) { - Log.w(TAG, "secure surface: could not resolve the React root (" - + t.getClass().getSimpleName() + ")"); - return null; - } - } - - /** - * Runs {@code work} on the UI thread and returns its result. View and - * RootView access is not safe off the UI thread; {@code - * secureSurfaceKey}/{@code secureSurfaceOrigin} are sync TurboModule - * methods called from the JS thread during measure. - */ - private T resolveOnUiThread(final T fallback, final java.util.concurrent.Callable work) { + private int[] readSurfaceOriginOnUiThread(final int key) { if (UiThreadUtil.isOnUiThread()) { - try { - return work.call(); - } catch (Exception e) { - Log.w(TAG, "secure surface resolve failed (" + e.getClass().getSimpleName() + ")"); - return fallback; - } + return originTracker.surfaceOriginNow(key); } - final AtomicReference result = new AtomicReference<>(fallback); + final AtomicReference result = new AtomicReference<>(); final CountDownLatch done = new CountDownLatch(1); UiThreadUtil.runOnUiThread(() -> { try { - result.set(work.call()); - } catch (Exception e) { - Log.w(TAG, "secure surface resolve failed (" + e.getClass().getSimpleName() + ")"); + result.set(originTracker.surfaceOriginNow(key)); + } catch (Throwable t) { + Log.w(TAG, "secure surface origin failed (" + t.getClass().getSimpleName() + ")"); } finally { done.countDown(); } }); try { - if (!done.await(2, TimeUnit.SECONDS)) { - Log.w(TAG, "secure surface resolve timed out"); - } + done.await(SURFACE_ORIGIN_WAIT_MS, TimeUnit.MILLISECONDS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/ReactRootOriginTracker.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/ReactRootOriginTracker.java index 5b97f053..25b54bb6 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/ReactRootOriginTracker.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/ReactRootOriginTracker.java @@ -15,34 +15,50 @@ import com.facebook.react.bridge.LifecycleEventListener; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.UiThreadUtil; +import com.facebook.react.bridge.UIManager; +import com.facebook.react.uimanager.ReactRoot; import com.facebook.react.uimanager.RootView; import com.facebook.react.uimanager.RootViewUtil; +import com.facebook.react.uimanager.UIManagerHelper; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.Arrays; import java.util.Iterator; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; /** * Keeps {@link SecureRectangleStore}'s per-surface display origins in step * with each React root view, so rectangles JS measured with {@code * measureInWindow} are served to the SDK in display pixels. * - *

The origin of a surface is that root's {@code getLocationOnScreen} less - * React Native's own viewport offset for it ({@link - * RootViewUtil#getViewportOffset}, the formula {@code ReactSurfaceView} feeds - * Fabric's layout constraints, which is what {@code measureInWindow} adds to - * a view's position in its root). With edge-to-edge on and a full-screen - * window that is (0, 0); with it off, it is the status-bar/cutout inset; in - * split-screen or freeform it is also the window's own position on the - * display. + *

The origin of the activity root (a {@link ReactRoot}) is its {@code + * getLocationOnScreen} less React Native's own viewport offset for it + * ({@link RootViewUtil#getViewportOffset}, the formula {@code + * ReactSurfaceView} feeds Fabric's layout constraints, which the root shadow + * node's {@code Translate(viewportOffset)} adds to {@code measureInWindow}). + * With edge-to-edge on and a full-screen window that is (0, 0); with it off, + * it is the status-bar/cutout inset; in split-screen or freeform it is also + * the window's own position on the display. * *

Fabric {@code measureInWindow} stops at the nearest {@code RootNodeKind} * ancestor ({@code ModalHostViewShadowNode} sets that trait), so a rectangle - * inside a {@code } is relative to the dialog's {@code - * DialogRootViewGroup}, not the activity root. Each surface therefore has its - * own origin; a single {@code setOrigin} for the display cannot serve both. + * inside a {@code } is relative to the modal's content, which is + * mounted in the dialog's {@code DialogRootViewGroup}. That root is a {@link + * RootView} but not a {@link ReactRoot}: nothing feeds its viewport offset to + * Fabric, and the modal host node's transform is the identity. Its origin is + * therefore its {@code getLocationOnScreen} alone ({@link #surfaceOrigin}). + * Each surface has its own origin; a single {@code setOrigin} for the display + * cannot serve both. + * + *

A surface's key is the React tag of its {@code } host, which JS + * reads off the fiber tree; {@link #watchSurface} resolves the dialog root + * from it when JS first publishes there, and retries on every refresh until + * the dialog root exists. * *

Re-read on every global layout of a watched root (rotation, insets * arriving, a window resize), on host resume, on every JS publish, and at @@ -92,6 +108,22 @@ private static final class WatchedRoot { } } + /** Finds a {@code }'s dialog root from its host's React tag. */ + private final SurfaceResolver surfaceResolver; + + /** + * Surface keys JS has published on whose dialog root could not be found + * yet. Retried on every {@link #refresh}. UI thread only. + */ + private final Set pendingSurfaces = new LinkedHashSet<>(); + + /** + * Each watched surface's last origin, for {@link #cachedSurfaceOrigin} + * (the {@code vh} walk asks from the JS thread). Written on the UI + * thread. + */ + private final Map surfaceOrigins = new ConcurrentHashMap<>(); + /** * A copy of the {@code {x, y}} last passed to {@link * SecureRectangleStore#setOrigin}, for {@link #currentOrigin()} (Task @@ -113,7 +145,8 @@ private static final class WatchedRoot { ReactRootOriginTracker(@NonNull final ReactApplicationContext context, @NonNull final SecureRectangleStore store) { - this(new ContextLifecycleSource(context), new ActivityRootFinder(context), store); + this(new ContextLifecycleSource(context), new ActivityRootFinder(context), + new ModalSurfaceResolver(context), store); } /** @@ -127,8 +160,16 @@ private static final class WatchedRoot { */ ReactRootOriginTracker(@NonNull final LifecycleSource lifecycle, @NonNull final RootFinder rootFinder, @NonNull final SecureRectangleStore store) { + this(lifecycle, rootFinder, surfaceKey -> null, store); + } + + /** Test seam, as above, with a fake {@link SurfaceResolver} too. */ + ReactRootOriginTracker(@NonNull final LifecycleSource lifecycle, + @NonNull final RootFinder rootFinder, @NonNull final SurfaceResolver surfaceResolver, + @NonNull final SecureRectangleStore store) { this.lifecycle = lifecycle; this.rootFinder = rootFinder; + this.surfaceResolver = surfaceResolver; this.store = store; lifecycle.addLifecycleEventListener(this); refreshSoon(); @@ -187,6 +228,7 @@ void refresh() { publishOrigin(current, true); } refreshWatched(); + resolvePending(); } catch (Throwable t) { Log.w(TAG, "secure rectangles: could not read the React root's display origin: " + t.getClass().getName()); @@ -194,30 +236,99 @@ void refresh() { } /** - * Tracks an additional React root (a {@code }'s dialog root) so - * its display origin is refreshed with the activity root. Safe from any - * thread: posts to the UI thread. + * JS published on {@code surfaceKey} (a {@code } host's tag): + * watch its dialog root. Safe from any thread: posts to the UI thread. + * Until the root is found its rectangles fail closed in the store. */ - void watch(@NonNull final RootHandle handle) { - UiThreadUtil.runOnUiThread(() -> watchNow(handle)); + void watchSurface(final int surfaceKey) { + if (surfaceKey <= SecureRectangleStore.MAIN_SURFACE) { + return; + } + UiThreadUtil.runOnUiThread(() -> watchSurfaceNow(surfaceKey)); } /** Package-visible for the same reason as {@link #refresh()}. */ @UiThread - void watchNow(@NonNull final RootHandle handle) { - if (disposed) { + void watchSurfaceNow(final int surfaceKey) { + if (disposed || surfaceKey <= SecureRectangleStore.MAIN_SURFACE || isWatched(surfaceKey)) { return; } - for (final WatchedRoot existing : watched) { - if (existing.handle.surfaceKey() == handle.surfaceKey()) { - return; + final RootHandle handle = resolveQuietly(surfaceKey); + if (handle == null) { + pendingSurfaces.add(surfaceKey); + return; + } + pendingSurfaces.remove(surfaceKey); + watchNow(handle); + } + + /** + * The display origin of {@code surfaceKey}'s dialog root, watching it + * first if needed; {@code null} when it cannot be found. For the {@code + * vh} walk, which asks once per {@code } per walk. + */ + @UiThread + @Nullable + int[] surfaceOriginNow(final int surfaceKey) { + watchSurfaceNow(surfaceKey); + for (final WatchedRoot entry : watched) { + if (entry.handle.surfaceKey() == surfaceKey) { + publishOrigin(entry.handle, false); } } + return cachedSurfaceOrigin(surfaceKey); + } + + /** The last origin read for {@code surfaceKey}, or {@code null}. Any thread. */ + @Nullable + int[] cachedSurfaceOrigin(final int surfaceKey) { + final int[] origin = surfaceOrigins.get(surfaceKey); + return origin == null ? null : origin.clone(); + } + + /** Package-visible for the same reason as {@link #refresh()}. */ + @UiThread + void watchNow(@NonNull final RootHandle handle) { + if (disposed || isWatched(handle.surfaceKey())) { + return; + } final LayoutListenerToken token = handle.addOnGlobalLayoutListener(this::refresh); watched.add(new WatchedRoot(handle, token)); publishOrigin(handle, false); } + @UiThread + private boolean isWatched(final int surfaceKey) { + for (final WatchedRoot existing : watched) { + if (existing.handle.surfaceKey() == surfaceKey) { + return true; + } + } + return false; + } + + @UiThread + @Nullable + private RootHandle resolveQuietly(final int surfaceKey) { + try { + return surfaceResolver.resolve(surfaceKey); + } catch (Throwable t) { + Log.w(TAG, "secure surface: could not resolve a dialog root (" + + t.getClass().getSimpleName() + ")"); + return null; + } + } + + @UiThread + private void resolvePending() { + if (pendingSurfaces.isEmpty()) { + return; + } + for (final Integer surfaceKey : new ArrayList<>(pendingSurfaces)) { + watchSurfaceNow(surfaceKey); + } + } + @UiThread private void refreshWatched() { final Iterator it = watched.iterator(); @@ -226,12 +337,28 @@ private void refreshWatched() { if (!entry.handle.isAttachedToWindow()) { it.remove(); entry.token.release(); + surfaceOrigins.remove(entry.handle.surfaceKey()); + store.dropSurfaceIfEmpty(entry.handle.surfaceKey()); continue; } publishOrigin(entry.handle, false); } } + /** + * Where {@code measureInWindow}'s (0, 0) sits on the display for the + * root in {@code snapshot}: {@code locationOnScreen - viewportOffset} for + * a {@link ReactRoot} (the activity root), {@code locationOnScreen} alone + * for any other {@link RootView} (a {@code }'s dialog root). Pure, + * so a JVM test checks the choice. + */ + @NonNull + static int[] surfaceOrigin(@NonNull final OriginSnapshot snapshot) { + return snapshot.reactRoot + ? SecureRectangleStore.displayOrigin(snapshot.onScreen, snapshot.viewport.x, snapshot.viewport.y) + : new int[] { snapshot.onScreen[0], snapshot.onScreen[1] }; + } + @UiThread private void publishOrigin(@NonNull final RootHandle handle, final boolean primary) { // Resolved once, atomically: the handle holds the root's view only @@ -246,20 +373,20 @@ private void publishOrigin(@NonNull final RootHandle handle, final boolean prima if (snapshot == null) { return; } - final int[] origin = - SecureRectangleStore.displayOrigin(snapshot.onScreen, snapshot.viewport.x, snapshot.viewport.y); - store.setOrigin(snapshot.displayId, handle.surfaceKey(), origin[0], origin[1]); - // Manual setSecureRectangles and legacy callers publish on - // MAIN_SURFACE; mirror the activity root there so they keep working. - if (primary && handle.surfaceKey() != SecureRectangleStore.MAIN_SURFACE) { - store.setOrigin(snapshot.displayId, SecureRectangleStore.MAIN_SURFACE, origin[0], origin[1]); - } + final int[] origin = surfaceOrigin(snapshot); + // The activity root is the main surface whatever its own view id: + // JS publishes everything outside a there. + final int surface = primary ? SecureRectangleStore.MAIN_SURFACE : handle.surfaceKey(); + store.setOrigin(snapshot.displayId, surface, origin[0], origin[1]); if (primary) { lastOrigin = origin; + } else { + surfaceOrigins.put(surface, origin.clone()); } if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "secure origin display=" + snapshot.displayId - + " surface=" + handle.surfaceKey() + + " surface=" + surface + + " kind=" + (primary ? "activity" : "dialog") + " onScreen=" + snapshot.onScreen[0] + "," + snapshot.onScreen[1] + " viewport=" + snapshot.viewport.x + "," + snapshot.viewport.y + " origin=" + origin[0] + "," + origin[1] @@ -293,6 +420,8 @@ void detach() { entry.token.release(); } watched.clear(); + pendingSurfaces.clear(); + surfaceOrigins.clear(); } @UiThread @@ -364,16 +493,34 @@ interface RootHandle { LayoutListenerToken addOnGlobalLayoutListener(Runnable onLayout); } - /** A root's location, viewport offset and display id, read together. */ + /** Finds a {@code }'s dialog root from its host's React tag. */ + interface SurfaceResolver { + /** UI thread. {@code null} while the dialog root does not exist. */ + @Nullable + RootHandle resolve(int surfaceKey); + } + + /** + * A root's location, viewport offset, display id and kind, read + * together. {@code reactRoot} is whether the root is a {@link ReactRoot} + * (whose viewport offset Fabric applies) rather than a dialog root. + */ static final class OriginSnapshot { final int[] onScreen; final Point viewport; final int displayId; + final boolean reactRoot; OriginSnapshot(final int[] onScreen, final Point viewport, final int displayId) { + this(onScreen, viewport, displayId, true); + } + + OriginSnapshot(final int[] onScreen, final Point viewport, final int displayId, + final boolean reactRoot) { this.onScreen = onScreen; this.viewport = viewport; this.displayId = displayId; + this.reactRoot = reactRoot; } } @@ -419,7 +566,43 @@ public RootHandle findCurrentRoot() { return null; } final View view = firstRootView(activity.getWindow().getDecorView()); - return view == null ? null : new ViewRootHandle(view); + return view == null ? null : new ViewRootHandle(view, ViewRootHandle.surfaceKeyOf(view)); + } + } + + /** + * Production {@link SurfaceResolver}: the {@code } host view for + * the tag forwards {@code getChildAt} to its {@code DialogRootViewGroup} + * ({@code ReactModalHostView}), so the dialog root is the React root of + * the host's first child. {@code null} until the modal has content. + */ + private static final class ModalSurfaceResolver implements SurfaceResolver { + private final ReactApplicationContext context; + + ModalSurfaceResolver(final ReactApplicationContext context) { + this.context = context; + } + + @Nullable + @Override + public RootHandle resolve(final int surfaceKey) { + if (!context.hasActiveReactInstance()) { + return null; + } + final UIManager uiManager = UIManagerHelper.getUIManagerForReactTag(context, surfaceKey); + if (uiManager == null) { + return null; + } + final View host = uiManager.resolveView(surfaceKey); + if (!(host instanceof ViewGroup) || ((ViewGroup) host).getChildCount() == 0) { + return null; + } + final View child = ((ViewGroup) host).getChildAt(0); + final RootView root = child == null ? null : RootViewUtil.getRootView(child); + if (!(root instanceof View) || root instanceof ReactRoot) { + return null; + } + return new ViewRootHandle((View) root, surfaceKey); } } @@ -449,9 +632,9 @@ private static final class ViewRootHandle implements RootHandle { private final WeakReference view; private final int surfaceKey; - ViewRootHandle(final View view) { + ViewRootHandle(final View view, final int surfaceKey) { this.view = new WeakReference<>(view); - this.surfaceKey = surfaceKeyOf(view); + this.surfaceKey = surfaceKey; } /** @@ -488,10 +671,13 @@ public OriginSnapshot resolveOrigin() { } final int[] onScreen = new int[2]; v.getLocationOnScreen(onScreen); + // Read for every root, so the log shows it; only a ReactRoot's is + // subtracted (surfaceOrigin). + final boolean reactRoot = v instanceof ReactRoot; final Point viewport = RootViewUtil.getViewportOffset(v); final Display display = v.getDisplay(); final int displayId = display == null ? Display.DEFAULT_DISPLAY : display.getDisplayId(); - return new OriginSnapshot(onScreen, viewport, displayId); + return new OriginSnapshot(onScreen, viewport, displayId, reactRoot); } @Override @@ -504,25 +690,6 @@ public LayoutListenerToken addOnGlobalLayoutListener(final Runnable onLayout) { } } - /** - * Builds a {@link RootHandle} for an already-resolved React root view - * (the activity root or a dialog's {@code DialogRootViewGroup}) and - * starts watching it. Package-visible so {@link BugseeModule} can - * register a surface when JS resolves {@code secureSurfaceKey}. - */ - @Nullable - static RootHandle handleFor(@Nullable final View rootView) { - return rootView == null ? null : new ViewRootHandle(rootView); - } - - /** - * The surface key {@link SecureRectangleStore} and JS share for {@code - * rootView}. Package-visible for {@link BugseeModule#secureSurfaceKey}. - */ - static int surfaceKeyOf(@NonNull final View rootView) { - return ViewRootHandle.surfaceKeyOf(rootView); - } - /** * Whether {@link ViewTreeObserverToken}'s listener should replace its * saved observer with the view's current one: only a cleared or dead diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/SecureRectangleStore.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/SecureRectangleStore.java index 114fa059..06a4b150 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/SecureRectangleStore.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/SecureRectangleStore.java @@ -39,9 +39,16 @@ * {@code ModalHostViewShadowNode} sets that trait). The activity React root * and a {@code }'s {@code DialogRootViewGroup} are different surfaces: * each rectangle is stored under the surface it was measured in, and served - * translated by that surface's display origin - * ({@code locationOnScreen - viewportOffset} of that root). A single origin - * for the whole display cannot serve both. + * translated by that surface's display origin (see {@link + * ReactRootOriginTracker#surfaceOrigin}). A single origin for the whole + * display cannot serve both. + * + *

Fails closed. A surface other than {@link #MAIN_SURFACE} whose origin + * has not been read yet serves its rectangles as one {@link #FULL_DISPLAY} + * rectangle: an unknown origin must not place a secure region anywhere it + * could miss, so until the tracker reads it the whole display is redacted. + * The main surface keeps (0, 0) until its origin is read, as before + * surfaces existed: the activity root is tracked from module start. * *

Threading

* @@ -78,35 +85,50 @@ final class SecureRectangleStore { */ static final int MAIN_SURFACE = 0; + /** + * What a surface whose origin is unknown serves in place of its + * rectangles: larger than any display, small enough that the SDK's own + * scaling cannot overflow an {@code int}. + */ + static final int[] FULL_DISPLAY = { 0, 0, 1 << 20, 1 << 20 }; + /** One surface's raw measurements and the origin that places them. */ private static final class Lane { - /** As JS published them: relative to this surface's viewport offset. */ + /** As JS published them: relative to this surface's own origin. */ final int[] raw; final int originX; final int originY; + /** Whether {@link #originX}/{@link #originY} were ever read. */ + final boolean originKnown; + /** Whether an unknown origin redacts the whole display (every non-main surface). */ + final boolean failClosed; /** {@link #raw} moved by this surface's origin: what the SDK is served. */ final int[] coordinates; - Lane(@NonNull final int[] raw, final int originX, final int originY) { - this(raw, originX, originY, translate(raw, originX, originY)); - } - - private Lane(@NonNull final int[] raw, final int originX, final int originY, - @NonNull final int[] coordinates) { + Lane(@NonNull final int[] raw, final int originX, final int originY, + final boolean originKnown, final boolean failClosed) { this.raw = raw; this.originX = originX; this.originY = originY; - this.coordinates = coordinates; + this.originKnown = originKnown; + this.failClosed = failClosed; + if (raw.length == 0) { + this.coordinates = NO_COORDINATES; + } else if (!originKnown && failClosed) { + this.coordinates = FULL_DISPLAY.clone(); + } else { + this.coordinates = translate(raw, originX, originY); + } } @NonNull Lane withRaw(@NonNull final int[] newRaw) { - return new Lane(newRaw, originX, originY); + return new Lane(newRaw, originX, originY, originKnown, failClosed); } @NonNull Lane withOrigin(final int newOriginX, final int newOriginY) { - return new Lane(raw, newOriginX, newOriginY); + return new Lane(raw, newOriginX, newOriginY, true, failClosed); } } @@ -260,6 +282,39 @@ void setOrigin(final int display, final int surface, final int originX, final in lane -> lane.withOrigin(originX, originY))); } + /** + * Forgets {@code surface} on every display where it holds no rectangles: + * its root is gone, and a later root may reuse the key. A surface that + * still holds rectangles stays (fails closed): only its owner clearing + * them removes the redaction. + */ + void dropSurfaceIfEmpty(final int surface) { + if (surface == MAIN_SURFACE) { + return; + } + for (final Integer display : byDisplay.keySet()) { + update(display, previous -> { + final Lane lane = previous.lanes.get(surface); + if (lane == null || lane.raw.length > 0) { + return previous; + } + final TreeMap nextLanes = new TreeMap<>(previous.lanes); + nextLanes.remove(surface); + return new Snapshot(0, nextLanes); + }); + } + } + + /** Whether {@code surface} has a lane on any display. Tests and the tracker read it. */ + boolean hasSurface(final int surface) { + for (final Snapshot snapshot : byDisplay.values()) { + if (snapshot.lanes.containsKey(surface)) { + return true; + } + } + return false; + } + /** * {@code locationOnScreen} of a React root less React Native's viewport * offset for it: the display-pixel position of the origin @@ -287,7 +342,9 @@ private static Snapshot withLane(@NonNull final Snapshot previous, final int sur @NonNull final LaneChange change) { final TreeMap nextLanes = new TreeMap<>(previous.lanes); final Lane prior = nextLanes.get(surface); - final Lane base = prior == null ? new Lane(NO_COORDINATES, 0, 0) : prior; + final Lane base = prior == null + ? new Lane(NO_COORDINATES, 0, 0, false, surface != MAIN_SURFACE) + : prior; nextLanes.put(surface, change.apply(base)); return new Snapshot(0, nextLanes); } diff --git a/packages/react-native/android/src/test/java/com/bugsee/reactnative/ReactRootOriginTrackerTest.java b/packages/react-native/android/src/test/java/com/bugsee/reactnative/ReactRootOriginTrackerTest.java index 0cc95809..f7145c4b 100644 --- a/packages/react-native/android/src/test/java/com/bugsee/reactnative/ReactRootOriginTrackerTest.java +++ b/packages/react-native/android/src/test/java/com/bugsee/reactnative/ReactRootOriginTrackerTest.java @@ -2,8 +2,10 @@ import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; import android.graphics.Point; @@ -60,6 +62,8 @@ private static final class FakeRoot implements RootHandle { Point viewport = new Point(); int displayId; int surfaceKey = SecureRectangleStore.MAIN_SURFACE; + /** A ReactRoot (the activity root) rather than a dialog root. */ + boolean reactRoot = true; /** Simulates the weakly-held view having been collected mid-refresh. */ boolean viewGone; int listenerRegistrations; @@ -80,7 +84,7 @@ public OriginSnapshot resolveOrigin() { if (viewGone) { return null; } - return new OriginSnapshot(new int[] { location[0], location[1] }, viewport, displayId); + return new OriginSnapshot(new int[] { location[0], location[1] }, viewport, displayId, reactRoot); } @Override @@ -388,8 +392,12 @@ public void aWatchedSurfacesOriginMovesOnlyItsRectangles() { main.surfaceKey = SecureRectangleStore.MAIN_SURFACE; final FakeRoot modal = new FakeRoot(); modal.location = new int[] { 40, 200 }; + // A dialog root is not a ReactRoot: Fabric never applies its viewport + // offset, so the offset RootViewUtil would report (here a translucent + // status bar's -51) must not move its rectangles. + modal.reactRoot = false; modal.viewport.x = 0; - modal.viewport.y = 0; + modal.viewport.y = -51; modal.displayId = 0; modal.surfaceKey = 42; final QueueRootFinder finder = new QueueRootFinder(); @@ -420,6 +428,177 @@ public void aWatchedSurfacesOriginMovesOnlyItsRectangles() { java.util.Arrays.copyOfRange(afterMainMove, 2, afterMainMove.length)); } + @Test + public void theActivityRootsOriginSubtractsItsViewportOffset() { + final OriginSnapshot snapshot = new OriginSnapshot(new int[] { 0, 51 }, new Point(), 0, true); + snapshot.viewport.y = 51; + assertArrayEquals(new int[] { 0, 0 }, ReactRootOriginTracker.surfaceOrigin(snapshot)); + } + + @Test + public void aDialogRootsOriginIsItsLocationOnScreenAlone() { + final OriginSnapshot snapshot = new OriginSnapshot(new int[] { 0, 0 }, new Point(), 0, false); + snapshot.viewport.y = -51; + assertArrayEquals(new int[] { 0, 0 }, ReactRootOriginTracker.surfaceOrigin(snapshot)); + } + + /** The activity root is the main surface whatever its own view id. */ + @Test + public void theActivityRootPublishesOnTheMainSurfaceOnly() { + final FakeRoot main = new FakeRoot(); + main.location = new int[] { 0, 51 }; + main.surfaceKey = 1; + final QueueRootFinder finder = new QueueRootFinder(); + finder.queue.add(main); + final SecureRectangleStore store = new SecureRectangleStore(); + store.set(0, new int[] { 10, 20, 30, 40 }); + + new ReactRootOriginTracker(new FakeLifecycleSource(), finder, store).refresh(); + + assertArrayEquals(new int[] { 10, 71, 30, 91 }, + java.util.Arrays.copyOfRange(store.snapshot(0), 2, 6)); + assertFalse(store.hasSurface(1)); + } + + /** A resolver whose answer a test sets. */ + private static final class FakeResolver implements ReactRootOriginTracker.SurfaceResolver { + RootHandle answer; + int calls; + + @Override + public RootHandle resolve(final int surfaceKey) { + calls++; + return answer; + } + } + + private static FakeRoot dialog(final int key, final int x, final int y) { + final FakeRoot dialog = new FakeRoot(); + dialog.surfaceKey = key; + dialog.reactRoot = false; + dialog.location = new int[] { x, y }; + return dialog; + } + + /** + * A 's dialog root that does not exist yet stays pending: its + * rectangles fail closed until a refresh finds it. + */ + @Test + public void aSurfaceWhoseDialogRootIsNotFoundYetFailsClosedUntilARefreshFindsIt() { + final FakeResolver resolver = new FakeResolver(); + final SecureRectangleStore store = new SecureRectangleStore(); + final ReactRootOriginTracker tracker = new ReactRootOriginTracker( + new FakeLifecycleSource(), new QueueRootFinder(), resolver, store); + store.set(0, 56, new int[] { 208, 560, 608, 760 }); + + tracker.watchSurfaceNow(56); + assertArrayEquals(SecureRectangleStore.FULL_DISPLAY, + java.util.Arrays.copyOfRange(store.snapshot(0), 2, 6)); + assertNull(tracker.cachedSurfaceOrigin(56)); + + resolver.answer = dialog(56, 0, 0); + tracker.refresh(); + + assertArrayEquals(new int[] { 208, 560, 608, 760 }, + java.util.Arrays.copyOfRange(store.snapshot(0), 2, 6)); + assertArrayEquals(new int[] { 0, 0 }, tracker.cachedSurfaceOrigin(56)); + } + + @Test + public void aWatchedSurfaceIsResolvedOnce() { + final FakeResolver resolver = new FakeResolver(); + resolver.answer = dialog(56, 0, 0); + final ReactRootOriginTracker tracker = new ReactRootOriginTracker( + new FakeLifecycleSource(), new QueueRootFinder(), resolver, new SecureRectangleStore()); + + tracker.watchSurfaceNow(56); + tracker.watchSurfaceNow(56); + tracker.refresh(); + + assertEquals(1, resolver.calls); + assertEquals(1, ((FakeRoot) resolver.answer).listenerRegistrations); + } + + @Test + public void theMainSurfaceAndUnknownKeysAreNeverResolved() { + final FakeResolver resolver = new FakeResolver(); + final ReactRootOriginTracker tracker = new ReactRootOriginTracker( + new FakeLifecycleSource(), new QueueRootFinder(), resolver, new SecureRectangleStore()); + + tracker.watchSurfaceNow(SecureRectangleStore.MAIN_SURFACE); + tracker.watchSurfaceNow(-1); + tracker.refresh(); + + assertEquals(0, resolver.calls); + } + + @Test + public void aThrowingResolverLeavesTheSurfacePending() { + final SecureRectangleStore store = new SecureRectangleStore(); + final int[] calls = { 0 }; + final ReactRootOriginTracker tracker = new ReactRootOriginTracker( + new FakeLifecycleSource(), new QueueRootFinder(), key -> { + calls[0]++; + throw new IllegalStateException("no view"); + }, store); + store.set(0, 56, new int[] { 1, 2, 3, 4 }); + + tracker.watchSurfaceNow(56); + tracker.refresh(); + + assertEquals(2, calls[0]); + assertArrayEquals(SecureRectangleStore.FULL_DISPLAY, + java.util.Arrays.copyOfRange(store.snapshot(0), 2, 6)); + } + + @Test + public void surfaceOriginNowReadsTheDialogRootsCurrentPlace() { + final FakeResolver resolver = new FakeResolver(); + final FakeRoot dialog = dialog(56, 0, 0); + resolver.answer = dialog; + final ReactRootOriginTracker tracker = new ReactRootOriginTracker( + new FakeLifecycleSource(), new QueueRootFinder(), resolver, new SecureRectangleStore()); + + assertArrayEquals(new int[] { 0, 0 }, tracker.surfaceOriginNow(56)); + dialog.location = new int[] { 0, 30 }; + assertArrayEquals(new int[] { 0, 30 }, tracker.surfaceOriginNow(56)); + assertNull(tracker.surfaceOriginNow(57)); + } + + /** A dialog root that leaves the window takes its empty lane with it. */ + @Test + public void aDetachedDialogRootDropsItsEmptyLaneAndItsOrigin() { + final FakeResolver resolver = new FakeResolver(); + final FakeRoot dialog = dialog(56, 0, 0); + resolver.answer = dialog; + final SecureRectangleStore store = new SecureRectangleStore(); + final ReactRootOriginTracker tracker = new ReactRootOriginTracker( + new FakeLifecycleSource(), new QueueRootFinder(), resolver, store); + store.set(0, 56, new int[] { 1, 2, 3, 4 }); + tracker.watchSurfaceNow(56); + store.set(0, 56, new int[0]); + assertTrue(store.hasSurface(56)); + + dialog.attached = false; + tracker.refresh(); + + assertFalse(store.hasSurface(56)); + assertNull(tracker.cachedSurfaceOrigin(56)); + } + + @Test + public void detachForgetsPendingSurfaces() { + final FakeResolver resolver = new FakeResolver(); + final ReactRootOriginTracker tracker = new ReactRootOriginTracker( + new FakeLifecycleSource(), new QueueRootFinder(), resolver, new SecureRectangleStore()); + tracker.watchSurfaceNow(56); + tracker.detach(); + tracker.refresh(); + + assertEquals(1, resolver.calls); + } + @Test public void constructorRegistersAsALifecycleListener() { final FakeLifecycleSource lifecycle = new FakeLifecycleSource(); diff --git a/packages/react-native/android/src/test/java/com/bugsee/reactnative/SecureRectangleStoreTest.java b/packages/react-native/android/src/test/java/com/bugsee/reactnative/SecureRectangleStoreTest.java index 7313bb47..4c7a3e3b 100644 --- a/packages/react-native/android/src/test/java/com/bugsee/reactnative/SecureRectangleStoreTest.java +++ b/packages/react-native/android/src/test/java/com/bugsee/reactnative/SecureRectangleStoreTest.java @@ -318,6 +318,68 @@ public void anotherSurfacesOwnOriginMovesOnlyItsRectangle() { java.util.Arrays.copyOfRange(packed, 2, packed.length)); } + // ---- Failing closed ------------------------------------------------- + + /** A 's rectangles before its origin is read redact the whole display. */ + @Test + public void aSurfaceWithNoOriginYetServesTheWholeDisplay() { + final SecureRectangleStore store = new SecureRectangleStore(); + store.set(DISPLAY, MODAL_SURFACE, new int[] { 100, 200, 150, 250, 1, 2, 3, 4 }); + + final int[] packed = store.snapshot(DISPLAY); + assertEquals(1, packed[1]); + assertArrayEquals(SecureRectangleStore.FULL_DISPLAY, + java.util.Arrays.copyOfRange(packed, 2, packed.length)); + } + + @Test + public void readingTheSurfacesOriginReplacesTheWholeDisplayRectangle() { + final SecureRectangleStore store = new SecureRectangleStore(); + store.set(DISPLAY, MODAL_SURFACE, new int[] { 100, 200, 150, 250 }); + final int before = store.snapshot(DISPLAY)[0]; + + store.setOrigin(DISPLAY, MODAL_SURFACE, 0, 0); + + final int[] packed = store.snapshot(DISPLAY); + assertNotEquals(before, packed[0]); + assertArrayEquals(new int[] { 100, 200, 150, 250 }, + java.util.Arrays.copyOfRange(packed, 2, packed.length)); + } + + /** The main surface keeps (0, 0) until its origin is read, as before surfaces existed. */ + @Test + public void theMainSurfaceWithNoOriginServesItsRectanglesUnmoved() { + final SecureRectangleStore store = new SecureRectangleStore(); + store.set(DISPLAY, new int[] { 10, 20, 30, 40 }); + + assertArrayEquals(new int[] { 10, 20, 30, 40 }, + java.util.Arrays.copyOfRange(store.snapshot(DISPLAY), 2, 6)); + } + + @Test + public void anEmptySurfaceWithNoOriginServesNothing() { + final SecureRectangleStore store = new SecureRectangleStore(); + store.set(DISPLAY, MODAL_SURFACE, new int[0]); + + assertEquals(0, store.snapshot(DISPLAY)[1]); + } + + @Test + public void dropSurfaceIfEmptyForgetsOnlyAnEmptyNonMainLane() { + final SecureRectangleStore store = new SecureRectangleStore(); + store.set(DISPLAY, MODAL_SURFACE, new int[0]); + store.set(DISPLAY, 43, new int[] { 1, 2, 3, 4 }); + store.set(DISPLAY, MAIN_SURFACE, new int[0]); + + store.dropSurfaceIfEmpty(MODAL_SURFACE); + store.dropSurfaceIfEmpty(43); + store.dropSurfaceIfEmpty(MAIN_SURFACE); + + assertFalse(store.hasSurface(MODAL_SURFACE)); + assertTrue(store.hasSurface(43)); + assertTrue(store.hasSurface(MAIN_SURFACE)); + } + /** Existing single-argument set/setOrigin keep meaning the main surface. */ @Test public void theLegacySetAndSetOriginTargetTheMainSurface() { diff --git a/packages/react-native/ios/BugseeModule.mm b/packages/react-native/ios/BugseeModule.mm index 97c2267a..e7851083 100644 --- a/packages/react-native/ios/BugseeModule.mm +++ b/packages/react-native/ios/BugseeModule.mm @@ -65,9 +65,10 @@ #import "BGSRNErrorMessage.h" #endif -/// Forward-declared: the wrapper's pull path refreshes the origin before the -/// function's definition later in this file. +/// Forward-declared: the wrapper's pull path refreshes the origins before the +/// functions' definitions later in this file. static NSValue *_Nullable BGSRNReactOrigin(void); +static NSValue *_Nullable BGSRNModalSurfaceOrigin(NSInteger surface); /// The conformance lives here rather than in the Support package so that the /// package stays buildable and testable without the SDK's headers. BGSRNWrapper @@ -109,18 +110,25 @@ - (void)onLifecycleEvent:(NSString *)eventType data:(id)data { /// secret must survive that swap. See `BGSRNSecureRectangles` for the version /// contract, which is what makes the SDK notice a change at all. - (NSData *)secureRectanglesForDisplay:(NSInteger)display { - // Refresh the main surface origin on each pull so a window move still - // updates without waiting for the next JS publish โ€” same reason Android - // refreshes at pull time. iOS Fabric Modal shares this window - // (`presentViewController:`), so one origin serves both. + // Every surface's origin is re-read on the pull, so a window move or a + // sheet settling still updates without waiting for the next JS publish, + // the same reason Android refreshes at pull time. The main surface is the + // React root's window; each is its presented view controller's + // view. A Modal whose host is gone takes its empty lane with it; one that + // is not found yet keeps failing closed. if (NSThread.isMainThread) { + BGSRNSecureRectangles *store = BGSRNSecureRectangles.shared; NSValue *origin = BGSRNReactOrigin(); if (origin != nil) { - CGPoint p = origin.CGPointValue; - [BGSRNSecureRectangles.shared setOriginX:(int32_t)llround(p.x) - originY:(int32_t)llround(p.y) - forDisplay:display - surface:BGSRNSecureMainSurface]; + [store setOrigin:origin.CGPointValue forDisplay:display]; + } + for (NSNumber *surface in [store surfacesForDisplay:display]) { + NSValue *modalOrigin = BGSRNModalSurfaceOrigin(surface.integerValue); + if (modalOrigin != nil) { + [store setOrigin:modalOrigin.CGPointValue forDisplay:display surface:surface.integerValue]; + } else { + [store dropSurfaceIfEmpty:surface.integerValue]; + } } } return [BGSRNSecureRectangles.shared snapshotForDisplay:display]; @@ -251,6 +259,84 @@ static void BGSRNSetWrapper(id _Nullable wrapper, BOOL onlyIfAbse }); } +/// A `` host's view controller. Declared here rather than imported: +/// `RCTModalHostViewComponentView` implements `viewController` without +/// declaring it in its header, and the call is guarded by +/// `respondsToSelector:`. +@interface UIView (BGSRNModalHost) +- (UIViewController *)viewController; +@end + +/// The `` host component views found so far, by React tag. Weak: a +/// host that unmounts must not be kept alive. Main thread only. +static NSMapTable *BGSRNModalHosts(void) { + static NSMapTable *hosts; + static dispatch_once_t once; + dispatch_once(&once, ^{ + hosts = [NSMapTable strongToWeakObjectsMapTable]; + }); + return hosts; +} + +/// The mounted `` host component view with React tag `surface`, among +/// the windows the SDK walks. Fabric sets each component view's `tag` to its +/// React tag (`RCTComponentViewRegistry`). nil when there is none. +static UIView *_Nullable BGSRNModalHost(NSInteger surface) { + static Class modalHostClass; + static dispatch_once_t once; + dispatch_once(&once, ^{ + modalHostClass = NSClassFromString(@"RCTModalHostViewComponentView"); + }); + if (modalHostClass == Nil || surface <= 0) { + return nil; + } + NSNumber *key = @(surface); + UIView *cached = [BGSRNModalHosts() objectForKey:key]; + if (cached != nil && cached.tag == surface && cached.window != nil) { + return cached; + } + UIWindow *keyWindow = BGSRNSdkKeyWindow(); + for (UIWindow *window in BGSRNSdkWalkedWindows(keyWindow)) { + UIView *found = [window viewWithTag:surface]; + if (found != nil && [found isKindOfClass:modalHostClass]) { + [BGSRNModalHosts() setObject:found forKey:key]; + return found; + } + } + [BGSRNModalHosts() removeObjectForKey:key]; + return nil; +} + +/// Where `measureInWindow`'s (0, 0) sits on the screen, in points, for the +/// `` whose host has React tag `surface`: the origin of its presented +/// view controller's view in its window, plus the window's `frame.origin`. +/// 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 the Modal is not presented, or off main. +static NSValue *_Nullable BGSRNModalSurfaceOrigin(NSInteger surface) { + if (!NSThread.isMainThread) { + return nil; + } + UIView *host = BGSRNModalHost(surface); + if (host == nil || ![host respondsToSelector:@selector(viewController)]) { + return nil; + } + UIViewController *controller = [host viewController]; + if (controller == nil || !controller.isViewLoaded) { + return nil; + } + UIView *content = controller.view; + UIWindow *window = content.window; + if (window == nil) { + return nil; + } + CGPoint inWindow = [content convertPoint:CGPointZero toView:nil]; + CGPoint origin = CGPointMake(inWindow.x + window.frame.origin.x, inWindow.y + window.frame.origin.y); + return [NSValue valueWithCGPoint:origin]; +} + static NSString *const kHandleDeadCode = @"E_REPORT_HANDLE_DEAD"; static NSString *const kCreateBusyCode = @"E_REPORT_CREATE_BUSY"; @@ -1003,35 +1089,14 @@ - (void)setSecureRectanglesOnSurface:(double)display free(flat); } -/// The store key for the React root that hosts `viewTag`. On iOS Fabric a -/// `` is presented in the same `UIWindow` as the main surface -/// (`RCTModalHostViewComponentView` uses `presentViewController:`), so both -/// publish on the main surface. Fabric `measureInWindow` for modal content -/// stops at `ModalHostView`'s `RootNodeKind` with an identity transform -/// (`LayoutableShadowNode.cpp`, `ModalHostViewShadowNode.h`), which is -/// already that window's coordinate space; the origin is the window's -/// `frame.origin` (what the SDK adds to every native node โ€” see -/// `BGSRNReactWindow.h`). -- (NSNumber *)secureSurfaceKey:(double)viewTag { - (void)viewTag; - BGSRNRunOnMainSync(^{ - NSValue *origin = BGSRNReactOrigin(); - if (origin != nil) { - CGPoint p = origin.CGPointValue; - [BGSRNSecureRectangles.shared setOriginX:(int32_t)llround(p.x) - originY:(int32_t)llround(p.y) - forDisplay:0 - surface:BGSRNSecureMainSurface]; - } - }); - return @(BGSRNSecureMainSurface); -} - -- (NSArray *)secureSurfaceOrigin:(double)viewTag { - (void)viewTag; +/// The screen origin `[x, y]` (points) of the `` whose host has React +/// tag `surface`, for the `vh` walk, which asks once per Modal per walk. +/// Empty when the Modal is not presented. +- (NSArray *)secureSurfaceOrigin:(double)surface { __block NSArray *origin = @[]; + const NSInteger key = (NSInteger)llround(surface); BGSRNRunOnMainSync(^{ - NSValue *value = BGSRNReactOrigin(); + NSValue *value = BGSRNModalSurfaceOrigin(key); if (value != nil) { CGPoint p = value.CGPointValue; origin = @[ @(p.x), @(p.y) ]; diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m index 22627c72..ae6957e4 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m @@ -10,10 +10,10 @@ /// as it is stable; the SDK only ever compares it against what it saw last. static const int32_t kInitialVersion = 1; -/// The activity / main React root's surface key. Legacy writers that do not -/// name a surface land here. const NSInteger BGSRNSecureMainSurface = 0; +const int32_t BGSRNSecureFullDisplay[4] = {0, 0, 1 << 20, 1 << 20}; + /// Steps the version, skipping the value that means "nothing secured yet": /// landing back on it would make a real set look identical to the empty one to /// an SDK that had only ever seen the latter. Wrapping takes 2^31 changes, so @@ -23,44 +23,65 @@ static int32_t BGSRNNextVersion(int32_t current) { return next == kInitialVersion ? kInitialVersion + 1 : next; } -static int32_t BGSRNSaturatedAdd(int32_t a, int32_t b) { - const int64_t sum = (int64_t)a + (int64_t)b; - if (sum > INT32_MAX) { +/// One coordinate moved by the origin, rounded outward: down for a left or top +/// edge, up for a right or bottom one, so the region can only grow. Saturates +/// at the int32 range. +static int32_t BGSRNMovedCoordinate(int32_t value, NSUInteger index, CGPoint origin) { + const BOOL isX = index % 2 == 0; + const BOOL isLeadingEdge = index % kCoordinatesPerRectangle < 2; + const double moved = (double)value + (isX ? origin.x : origin.y); + const double rounded = isLeadingEdge ? floor(moved) : ceil(moved); + if (rounded >= (double)INT32_MAX) { return INT32_MAX; } - if (sum < INT32_MIN) { + if (rounded <= (double)INT32_MIN) { return INT32_MIN; } - return (int32_t)sum; + return (int32_t)rounded; +} + +/// `raw` (int32 coordinates) with every coordinate moved by `origin`. +static NSData *BGSRNMovedCoordinates(NSData *raw, CGPoint origin) { + if (origin.x == 0 && origin.y == 0) { + return raw; + } + const NSUInteger count = raw.length / sizeof(int32_t); + NSMutableData *moved = [NSMutableData dataWithLength:raw.length]; + const int32_t *in = (const int32_t *)raw.bytes; + int32_t *out = (int32_t *)moved.mutableBytes; + for (NSUInteger i = 0; i < count; i++) { + out[i] = BGSRNMovedCoordinate(in[i], i, origin); + } + return [moved copy]; } -/// One surface's raw measurements and the origin that places them. +/// One surface's raw measurements and the origin that places them. Immutable: +/// a change replaces the lane. @interface BGSRNSecureLane : NSObject -@property (nonatomic, copy) NSData *raw; -@property (nonatomic, assign) int32_t originX; -@property (nonatomic, assign) int32_t originY; -@property (nonatomic, copy, readonly) NSData *coordinates; +@property (nonatomic, copy, readonly) NSData *raw; +@property (nonatomic, assign, readonly) CGPoint origin; +@property (nonatomic, assign, readonly) BOOL originKnown; +/// What the SDK is served for this lane. +@property (nonatomic, copy, readonly) NSData *served; @end @implementation BGSRNSecureLane -- (instancetype)initWithRaw:(NSData *)raw originX:(int32_t)originX originY:(int32_t)originY { +- (instancetype)initWithRaw:(NSData *)raw + origin:(CGPoint)origin + originKnown:(BOOL)originKnown + failClosed:(BOOL)failClosed { self = [super init]; if (self) { _raw = [raw copy] ?: [NSData data]; - _originX = originX; - _originY = originY; - const NSUInteger count = _raw.length / sizeof(int32_t); - if (count == 0) { - _coordinates = [NSData data]; + _origin = origin; + _originKnown = originKnown; + if (_raw.length == 0) { + _served = [NSData data]; + } else if (!originKnown && failClosed) { + _served = [NSData dataWithBytes:BGSRNSecureFullDisplay length:sizeof(BGSRNSecureFullDisplay)]; } else { - NSMutableData *moved = [NSMutableData dataWithLength:count * sizeof(int32_t)]; - const int32_t *in = (const int32_t *)_raw.bytes; - int32_t *out = (int32_t *)moved.mutableBytes; - for (NSUInteger i = 0; i < count; i++) { - out[i] = BGSRNSaturatedAdd(in[i], (i % 2 == 0) ? originX : originY); - } - _coordinates = [moved copy]; + _served = BGSRNMovedCoordinates(_raw, origin); } } return self; @@ -69,10 +90,13 @@ - (instancetype)initWithRaw:(NSData *)raw originX:(int32_t)originX originY:(int3 @end @implementation BGSRNSecureRectangles { - /// display -> (surface -> lane). Surfaces sorted by key when serving. + /// display -> (surface -> lane). Served in surface-key order. NSMutableDictionary *> *_lanesByDisplay; - /// display -> its current version. + /// display -> its current version. Absent until the served set first changes. NSMutableDictionary *_versionsByDisplay; + /// Serialises the JS-thread write against the main-thread pull. A plain lock + /// rather than a queue: the pull happens on main once per captured frame and + /// must not be made to hop. NSLock *_lock; } @@ -112,77 +136,114 @@ - (BOOL)setCoordinates:(const int32_t *)coordinates return NO; } + // Copied on the way in: the caller keeps its buffer, and a later write + // through it would edit a snapshot the SDK is reading. NSData *published = count == 0 ? [NSData data] : [NSData dataWithBytes:coordinates length:count * sizeof(int32_t)]; - NSNumber *displayKey = @(display); - NSNumber *surfaceKey = @(surface); + [_lock lock]; + [self changeLaneLockedForDisplay:display + surface:surface + change:^BGSRNSecureLane *(BGSRNSecureLane *prior, BOOL failClosed) { + return [[BGSRNSecureLane alloc] initWithRaw:published + origin:prior.origin + originKnown:prior.originKnown + failClosed:failClosed]; + }]; + [_lock unlock]; + return YES; +} + +- (void)setOrigin:(CGPoint)origin forDisplay:(NSInteger)display { + [self setOrigin:origin forDisplay:display surface:BGSRNSecureMainSurface]; +} + +- (void)setOrigin:(CGPoint)origin forDisplay:(NSInteger)display surface:(NSInteger)surface { [_lock lock]; - NSData *previousServed = [self servedCoordinatesForDisplayLocked:display]; - NSMutableDictionary *lanes = _lanesByDisplay[displayKey]; - if (lanes == nil) { - lanes = [NSMutableDictionary dictionary]; - _lanesByDisplay[displayKey] = lanes; - } - BGSRNSecureLane *prior = lanes[surfaceKey]; - int32_t originX = prior ? prior.originX : 0; - int32_t originY = prior ? prior.originY : 0; - lanes[surfaceKey] = [[BGSRNSecureLane alloc] initWithRaw:published originX:originX originY:originY]; - NSData *nextServed = [self servedCoordinatesForDisplayLocked:display]; - if (![previousServed isEqualToData:nextServed]) { - const int32_t currentVersion = _versionsByDisplay[displayKey] - ? _versionsByDisplay[displayKey].intValue - : kInitialVersion; - _versionsByDisplay[displayKey] = @(BGSRNNextVersion(currentVersion)); + BGSRNSecureLane *current = _lanesByDisplay[@(display)][@(surface)]; + // The usual case: re-read on every pull, the window has not moved. + if (current != nil && current.originKnown && CGPointEqualToPoint(current.origin, origin)) { + [_lock unlock]; + return; } + [self changeLaneLockedForDisplay:display + surface:surface + change:^BGSRNSecureLane *(BGSRNSecureLane *prior, BOOL failClosed) { + return [[BGSRNSecureLane alloc] initWithRaw:prior.raw + origin:origin + originKnown:YES + failClosed:failClosed]; + }]; [_lock unlock]; +} - return YES; +- (NSArray *)surfacesForDisplay:(NSInteger)display { + [_lock lock]; + NSMutableArray *surfaces = [NSMutableArray array]; + for (NSNumber *key in _lanesByDisplay[@(display)]) { + if (key.integerValue != BGSRNSecureMainSurface) { + [surfaces addObject:key]; + } + } + [_lock unlock]; + return [surfaces sortedArrayUsingSelector:@selector(compare:)]; } -- (void)setOriginX:(int32_t)originX originY:(int32_t)originY forDisplay:(NSInteger)display { - [self setOriginX:originX originY:originY forDisplay:display surface:BGSRNSecureMainSurface]; +- (void)dropSurfaceIfEmpty:(NSInteger)surface { + if (surface == BGSRNSecureMainSurface) { + return; + } + [_lock lock]; + for (NSNumber *display in _lanesByDisplay.allKeys) { + BGSRNSecureLane *lane = _lanesByDisplay[display][@(surface)]; + if (lane != nil && lane.raw.length == 0) { + // An empty lane serves nothing, so removing it leaves the version alone. + [_lanesByDisplay[display] removeObjectForKey:@(surface)]; + } + } + [_lock unlock]; } -- (void)setOriginX:(int32_t)originX - originY:(int32_t)originY - forDisplay:(NSInteger)display - surface:(NSInteger)surface { +/// Replaces one lane and moves the display's version only when what is served +/// changes. Caller holds `_lock`. +- (void)changeLaneLockedForDisplay:(NSInteger)display + surface:(NSInteger)surface + change:(BGSRNSecureLane * (^)(BGSRNSecureLane *prior, BOOL failClosed))change { NSNumber *displayKey = @(display); NSNumber *surfaceKey = @(surface); - - [_lock lock]; - NSData *previousServed = [self servedCoordinatesForDisplayLocked:display]; + NSData *previousServed = [self servedLockedForDisplay:display]; NSMutableDictionary *lanes = _lanesByDisplay[displayKey]; if (lanes == nil) { lanes = [NSMutableDictionary dictionary]; _lanesByDisplay[displayKey] = lanes; } - BGSRNSecureLane *prior = lanes[surfaceKey]; - NSData *raw = prior ? prior.raw : [NSData data]; - lanes[surfaceKey] = [[BGSRNSecureLane alloc] initWithRaw:raw originX:originX originY:originY]; - NSData *nextServed = [self servedCoordinatesForDisplayLocked:display]; + const BOOL failClosed = surface != BGSRNSecureMainSurface; + BGSRNSecureLane *prior = lanes[surfaceKey] + ?: [[BGSRNSecureLane alloc] initWithRaw:[NSData data] + origin:CGPointZero + originKnown:NO + failClosed:failClosed]; + lanes[surfaceKey] = change(prior, failClosed); + NSData *nextServed = [self servedLockedForDisplay:display]; if (![previousServed isEqualToData:nextServed]) { const int32_t currentVersion = _versionsByDisplay[displayKey] ? _versionsByDisplay[displayKey].intValue : kInitialVersion; _versionsByDisplay[displayKey] = @(BGSRNNextVersion(currentVersion)); } - [_lock unlock]; } -/// Caller holds `_lock`. -- (NSData *)servedCoordinatesForDisplayLocked:(NSInteger)display { +/// Every lane's served coordinates, in surface-key order. Caller holds `_lock`. +- (NSData *)servedLockedForDisplay:(NSInteger)display { NSMutableDictionary *lanes = _lanesByDisplay[@(display)]; if (lanes.count == 0) { return [NSData data]; } - NSArray *keys = [lanes.allKeys sortedArrayUsingSelector:@selector(compare:)]; NSMutableData *merged = [NSMutableData data]; - for (NSNumber *key in keys) { - [merged appendData:lanes[key].coordinates]; + for (NSNumber *key in [lanes.allKeys sortedArrayUsingSelector:@selector(compare:)]) { + [merged appendData:lanes[key].served]; } return [merged copy]; } @@ -191,13 +252,11 @@ - (NSData *)snapshotForDisplay:(NSInteger)display { NSNumber *key = @(display); [_lock lock]; - NSData *coordinates = [self servedCoordinatesForDisplayLocked:display]; - // Lanes can exist without a version entry: an origin-only write that leaves - // the served buffer empty deliberately does not bump. Messaging a missing - // NSNumber yields 0, which is not the empty-set version (kInitialVersion). - const int32_t version = _versionsByDisplay[key] - ? _versionsByDisplay[key].intValue - : kInitialVersion; + NSData *coordinates = [self servedLockedForDisplay:display]; + // Keyed off the version itself, not the lanes: an origin-only write creates + // a lane without moving the version, and must still report the initial one. + const int32_t version = + _versionsByDisplay[key] ? _versionsByDisplay[key].intValue : kInitialVersion; [_lock unlock]; const NSUInteger coordinateCount = coordinates.length / sizeof(int32_t); @@ -205,6 +264,8 @@ - (NSData *)snapshotForDisplay:(NSInteger)display { NSMutableData *packed = [NSMutableData dataWithLength:(kHeaderInts + coordinateCount) * sizeof(int32_t)]; int32_t *out = (int32_t *)packed.mutableBytes; + // The SDK reads raw int32s, so the encoding is explicit rather than whatever + // this host happens to be. out[0] = (int32_t)CFSwapInt32HostToLittle((uint32_t)version); out[1] = (int32_t)CFSwapInt32HostToLittle( (uint32_t)(coordinateCount / kCoordinatesPerRectangle)); @@ -216,6 +277,7 @@ - (NSData *)snapshotForDisplay:(NSInteger)display { } } + // Immutable to the caller: the SDK may hold it across our next write. return [packed copy]; } 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 cecd3f08..7d12673e 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSecureRectangles.h +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSecureRectangles.h @@ -1,11 +1,16 @@ +#import #import NS_ASSUME_NONNULL_BEGIN -/// The activity / main React root's surface key. Legacy writers that do not -/// name a surface land here. +/// The app's own React root's surface key. Legacy writers that do not name a +/// surface land here. FOUNDATION_EXPORT const NSInteger BGSRNSecureMainSurface; +/// What a surface whose origin is unknown serves in place of its rectangles: +/// one rectangle larger than any screen, `{0, 0, 2^20, 2^20}`. +FOUNDATION_EXPORT const int32_t BGSRNSecureFullDisplay[4]; + /// The regions the app has asked Bugsee not to record, in the form the SDK /// pulls them. /// @@ -25,12 +30,21 @@ FOUNDATION_EXPORT const NSInteger BGSRNSecureMainSurface; /// per display: a change on one screen must not invalidate another's. /// /// Fabric `measureInWindow` is relative to the measured node's nearest -/// `RootNodeKind` ancestor. On Android a `` dialog is a second -/// surface key with its own origin; rectangles are stored under the surface -/// they were measured in and served translated by that surface's origin. On -/// iOS a Fabric Modal is `presentViewController:` on the same `UIWindow`, so -/// it publishes on `BGSRNSecureMainSurface` with that window's -/// `frame.origin` โ€” not a second origin lane. +/// `RootNodeKind` ancestor: the app's root, or a ``'s content (the +/// `ModalHostView` node, whose transform is the identity). Each rectangle is +/// stored under the surface it was measured in (the Modal host's React tag, +/// or `BGSRNSecureMainSurface`) and served moved by that surface's origin in +/// screen points. A Modal's origin is where its presented view controller's +/// view sits: the window's origin for a full-screen Modal, inset for a +/// `pageSheet` or `formSheet` one. A single origin for the display cannot +/// serve both. +/// +/// Origins can be fractional. Left and top edges are moved and rounded down, +/// right and bottom ones up: a rectangle may grow by under a point, never +/// shrink. Edges saturate at the int32 range rather than wrap. +/// +/// Fails closed: a surface other than the main one whose origin has not been +/// recorded yet serves `BGSRNSecureFullDisplay` instead of its rectangles. /// /// ## Threading /// @@ -65,15 +79,21 @@ FOUNDATION_EXPORT const NSInteger BGSRNSecureMainSurface; forDisplay:(NSInteger)display surface:(NSInteger)surface; -/// Records where the main React root's viewport origin sits on `display`. -- (void)setOriginX:(int32_t)originX originY:(int32_t)originY forDisplay:(NSInteger)display; +/// Records where the main surface's window sits on `display`'s screen, in +/// points. Re-recording the same origin costs nothing. +- (void)setOrigin:(CGPoint)origin forDisplay:(NSInteger)display; + +/// Records where one surface's `measureInWindow` (0, 0) sits on `display`'s +/// screen, in points. Only that surface's rectangles move. +- (void)setOrigin:(CGPoint)origin forDisplay:(NSInteger)display surface:(NSInteger)surface; + +/// The surfaces other than the main one that `display` has lanes for, in key +/// order: the ones whose origin the pull refreshes. +- (NSArray *)surfacesForDisplay:(NSInteger)display; -/// Records where one surface's origin sits on `display`. Only that surface's -/// rectangles move. -- (void)setOriginX:(int32_t)originX - originY:(int32_t)originY - forDisplay:(NSInteger)display - surface:(NSInteger)surface; +/// Forgets `surface` on every display where it holds no rectangles (its +/// Modal is gone). A surface that still holds rectangles stays. +- (void)dropSurfaceIfEmpty:(NSInteger)surface; /// The buffer for `display`. A display nothing has secured reports an empty /// set rather than nil, so the SDK always has a version to compare against. diff --git a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m index de8ac82f..93759139 100644 --- a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m +++ b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m @@ -148,7 +148,7 @@ - (void)testIgnoresACoordinateListThatIsNotWholeRectangles { /// snapshot at the empty-set version (1), not message-nil's 0. Android's /// empty snapshot stays at version 1 the same way. - (void)testAnOriginOnlyWriteThatLeavesServedEmptyKeepsVersionOne { - [_store setOriginX:0 originY:96 forDisplay:0]; + [_store setOrigin:CGPointMake(0, 96) forDisplay:0]; NSArray *packed = [self unpack:[_store snapshotForDisplay:0]]; XCTAssertEqualObjects(packed[0], @1, @@ -163,8 +163,8 @@ - (void)testTheMainOriginDoesNotMoveAnotherSurfacesRectangle { const int32_t modalRects[] = {100, 200, 150, 250}; [_store setCoordinates:mainRects count:4 forDisplay:0 surface:BGSRNSecureMainSurface]; [_store setCoordinates:modalRects count:4 forDisplay:0 surface:42]; - [_store setOriginX:0 originY:96 forDisplay:0 surface:BGSRNSecureMainSurface]; - [_store setOriginX:0 originY:0 forDisplay:0 surface:42]; + [_store setOrigin:CGPointMake(0, 96) forDisplay:0 surface:BGSRNSecureMainSurface]; + [_store setOrigin:CGPointMake(0, 0) forDisplay:0 surface:42]; NSArray *packed = [self unpack:[_store snapshotForDisplay:0]]; XCTAssertEqualObjects(packed[1], @2); @@ -177,8 +177,8 @@ - (void)testAnotherSurfacesOwnOriginMovesOnlyItsRectangle { const int32_t modalRects[] = {100, 200, 150, 250}; [_store setCoordinates:mainRects count:4 forDisplay:0 surface:BGSRNSecureMainSurface]; [_store setCoordinates:modalRects count:4 forDisplay:0 surface:42]; - [_store setOriginX:0 originY:96 forDisplay:0 surface:BGSRNSecureMainSurface]; - [_store setOriginX:7 originY:40 forDisplay:0 surface:42]; + [_store setOrigin:CGPointMake(0, 96) forDisplay:0 surface:BGSRNSecureMainSurface]; + [_store setOrigin:CGPointMake(7, 40) forDisplay:0 surface:42]; NSArray *packed = [self unpack:[_store snapshotForDisplay:0]]; XCTAssertEqualObjects(packed[1], @2); @@ -186,6 +186,103 @@ - (void)testAnotherSurfacesOwnOriginMovesOnlyItsRectangle { (@[ @10, @116, @30, @136, @107, @240, @157, @290 ])); } +/// A Modal's rectangles before its origin is recorded redact the whole screen. +- (void)testASurfaceWithNoOriginYetServesTheWholeDisplay { + const int32_t modalRects[] = {100, 200, 150, 250, 1, 2, 3, 4}; + [_store setCoordinates:modalRects count:8 forDisplay:0 surface:42]; + + NSArray *packed = [self unpack:[_store snapshotForDisplay:0]]; + XCTAssertEqualObjects(packed[1], @1); + XCTAssertEqualObjects([packed subarrayWithRange:NSMakeRange(2, 4)], + (@[ @0, @0, @(1 << 20), @(1 << 20) ])); +} + +- (void)testRecordingTheSurfacesOriginReplacesTheWholeDisplayRectangle { + const int32_t modalRects[] = {100, 200, 150, 250}; + [_store setCoordinates:modalRects count:4 forDisplay:0 surface:42]; + NSNumber *before = [self unpack:[_store snapshotForDisplay:0]][0]; + + [_store setOrigin:CGPointZero forDisplay:0 surface:42]; + + NSArray *packed = [self unpack:[_store snapshotForDisplay:0]]; + XCTAssertNotEqualObjects(packed[0], before); + XCTAssertEqualObjects([packed subarrayWithRange:NSMakeRange(2, 4)], (@[ @100, @200, @150, @250 ])); +} + +/// The main surface keeps (0, 0) until its origin is recorded. +- (void)testTheMainSurfaceWithNoOriginServesItsRectanglesUnmoved { + const int32_t rects[] = {10, 20, 30, 40}; + [_store setCoordinates:rects count:4 forDisplay:0]; + + XCTAssertEqualObjects([[self unpack:[_store snapshotForDisplay:0]] subarrayWithRange:NSMakeRange(2, 4)], + (@[ @10, @20, @30, @40 ])); +} + +/// A fractional origin grows the rectangle by under a point, never shrinks it. +- (void)testAFractionalOriginRoundsEveryEdgeOutward { + const int32_t rects[] = {10, 20, 30, 40}; + [_store setCoordinates:rects count:4 forDisplay:0 surface:42]; + [_store setOrigin:CGPointMake(0.5, 59.25) forDisplay:0 surface:42]; + + XCTAssertEqualObjects([[self unpack:[_store snapshotForDisplay:0]] subarrayWithRange:NSMakeRange(2, 4)], + (@[ @10, @79, @31, @100 ])); +} + +- (void)testANegativeFractionalOriginAlsoRoundsOutward { + const int32_t rects[] = {10, 20, 30, 40}; + [_store setCoordinates:rects count:4 forDisplay:0]; + [_store setOrigin:CGPointMake(-0.5, -0.5) forDisplay:0]; + + XCTAssertEqualObjects([[self unpack:[_store snapshotForDisplay:0]] subarrayWithRange:NSMakeRange(2, 4)], + (@[ @9, @19, @30, @40 ])); +} + +- (void)testAnOriginSaturatesInsteadOfWrapping { + const int32_t rects[] = {INT32_MAX - 1, INT32_MIN + 1, INT32_MAX - 1, INT32_MIN + 1}; + [_store setCoordinates:rects count:4 forDisplay:0]; + [_store setOrigin:CGPointMake(10, -10) forDisplay:0]; + + XCTAssertEqualObjects([[self unpack:[_store snapshotForDisplay:0]] subarrayWithRange:NSMakeRange(2, 4)], + (@[ @(INT32_MAX), @((uint32_t)INT32_MIN), @(INT32_MAX), @((uint32_t)INT32_MIN) ]), + @"unpack reads each int32's bits as unsigned"); +} + +- (void)testRecordingTheSameOriginAgainHoldsTheVersion { + const int32_t rects[] = {10, 20, 30, 40}; + [_store setCoordinates:rects count:4 forDisplay:0 surface:42]; + [_store setOrigin:CGPointMake(0, 59) forDisplay:0 surface:42]; + NSNumber *settled = [self unpack:[_store snapshotForDisplay:0]][0]; + + [_store setOrigin:CGPointMake(0, 59) forDisplay:0 surface:42]; + + XCTAssertEqualObjects([self unpack:[_store snapshotForDisplay:0]][0], settled); +} + +- (void)testListsTheSurfacesOtherThanTheMainOne { + const int32_t rects[] = {1, 2, 3, 4}; + [_store setCoordinates:rects count:4 forDisplay:0]; + [_store setCoordinates:rects count:4 forDisplay:0 surface:78]; + [_store setCoordinates:rects count:4 forDisplay:0 surface:42]; + [_store setCoordinates:rects count:4 forDisplay:1 surface:99]; + + XCTAssertEqualObjects([_store surfacesForDisplay:0], (@[ @42, @78 ])); +} + +- (void)testDropsOnlyAnEmptyNonMainSurface { + const int32_t rects[] = {1, 2, 3, 4}; + [_store setCoordinates:NULL count:0 forDisplay:0 surface:42]; + [_store setCoordinates:rects count:4 forDisplay:0 surface:78]; + [_store setCoordinates:NULL count:0 forDisplay:0]; + NSNumber *before = [self unpack:[_store snapshotForDisplay:0]][0]; + + [_store dropSurfaceIfEmpty:42]; + [_store dropSurfaceIfEmpty:78]; + [_store dropSurfaceIfEmpty:BGSRNSecureMainSurface]; + + XCTAssertEqualObjects([_store surfacesForDisplay:0], (@[ @78 ])); + XCTAssertEqualObjects([self unpack:[_store snapshotForDisplay:0]][0], before); +} + /// The shared store outlives any one wrapper: the init provider registers one /// before launch and setWrapperInfo swaps in another, and the regions the app /// marked secret must survive that. diff --git a/packages/react-native/src/NativeBugsee.ts b/packages/react-native/src/NativeBugsee.ts index 9c6929c3..0481e6e4 100644 --- a/packages/react-native/src/NativeBugsee.ts +++ b/packages/react-native/src/NativeBugsee.ts @@ -58,15 +58,17 @@ export interface Spec extends TurboModule { * * An empty list clears the display's main-surface set. Rectangles measured * inside a React Native `` go through - * {@link setSecureRectanglesOnSurface} with that surface's key from - * {@link secureSurfaceKey}. + * {@link setSecureRectanglesOnSurface} instead. */ setSecureRectangles(display: number, coordinates: number[]): void; /** - * Like {@link setSecureRectangles}, for one React surface on the display. - * Fabric `measureInWindow` is relative to that surface's root - * (`ModalHostView` / `DialogRootViewGroup` for a ``), so each - * surface keeps its own origin at pull time. + * Like {@link setSecureRectangles}, for one React surface on the display: + * `surface` is the React tag of the `` host the rectangles were + * measured in (JS reads it off the fiber tree), or `-1` for a Modal whose + * tag is unknown. Fabric `measureInWindow` is relative to that Modal's + * content, so native finds and watches the Modal's root on the first + * publish and translates the rectangles by its origin at pull time. Until + * that origin is read, the surface redacts the whole display. */ setSecureRectanglesOnSurface( display: number, @@ -74,16 +76,11 @@ export interface Spec extends TurboModule { coordinates: number[], ): void; /** - * The store key for the React root that hosts `viewTag` (the activity root - * or a modal's dialog root). Registers that root for origin refresh. - * Returns {@code 0} (the main surface) when the view cannot be resolved. + * The display origin `[x, y]` of the `` whose host has React tag + * `surface`, in the units of the `vh` request's `originX`/`originY`. Empty + * when unknown. Synchronous: the `vh` walk asks once per Modal per walk. */ - secureSurfaceKey(viewTag: number): number; - /** - * Display origin `[x, y]` of the React root hosting `viewTag`, in the same - * units as the `vh` request's `originX`/`originY`. Empty when unknown. - */ - secureSurfaceOrigin(viewTag: number): number[]; + secureSurfaceOrigin(surface: number): number[]; /** * Suppresses everything that describes the screen: the video (black diff --git a/packages/react-native/src/__mocks__/native.ts b/packages/react-native/src/__mocks__/native.ts index 6cdadced..193c8c5a 100644 --- a/packages/react-native/src/__mocks__/native.ts +++ b/packages/react-native/src/__mocks__/native.ts @@ -20,8 +20,7 @@ const SYNC_RETURNS: Record = { noteConsoleEcho: true, setAppearanceColor: true, getAppearanceColor: '', - // An unresolvable view: the main surface, and no origin of its own. - secureSurfaceKey: 0, + // An unknown surface: no origin of its own. secureSurfaceOrigin: [], }; @@ -112,7 +111,6 @@ export const native = { setWrapperInfo: jest.fn]>(), setSecureRectangles: jest.fn(), setSecureRectanglesOnSurface: jest.fn(), - secureSurfaceKey: jest.fn(), secureSurfaceOrigin: jest.fn(), startBlackout: jest.fn(), endBlackout: jest.fn(), diff --git a/packages/react-native/src/secure/BugseeSecure.tsx b/packages/react-native/src/secure/BugseeSecure.tsx index 79bc16c6..12b91392 100644 --- a/packages/react-native/src/secure/BugseeSecure.tsx +++ b/packages/react-native/src/secure/BugseeSecure.tsx @@ -2,9 +2,9 @@ import { useLayoutEffect, useRef } from 'react'; import type { ComponentRef, ReactElement, ReactNode } from 'react'; import { View } from 'react-native'; import type { LayoutChangeEvent, ViewProps } from 'react-native'; -import NativeBugsee from '../NativeBugsee'; import { addMeasurer } from './measureLoop'; -import { clearOwner, MAIN_SURFACE, setOwnerRectangles } from './registry'; +import { clearOwner, setOwnerRectangles } from './registry'; +import { surfaceOfInstance } from './surface'; import { errorName } from '../errorName'; export interface BugseeSecureProps extends ViewProps { @@ -27,10 +27,11 @@ export interface BugseeSecureProps extends ViewProps { * rectangle published. Only unmounting or `enabled={false}` removes it. * * Fabric `measureInWindow` is relative to the measured node's nearest - * `RootNodeKind` ancestor (the activity React root, or a ``'s - * `DialogRootViewGroup` / `ModalHostView`). The rectangle is published under - * that surface's key so native can translate it by that surface's display - * origin at pull time โ€” not the activity root's origin alone. + * `RootNodeKind` ancestor: the app's root, or the content of the `` + * 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. * * A plain function component on purpose (no `memo`, no `forwardRef`): the view * tree walk recognises it by identity. @@ -46,6 +47,9 @@ export function BugseeSecure(props: BugseeSecureProps): ReactElement { // disable must not bring the region back. const active = useRef(false); const warned = useRef(false); + // The surface this view is measured in. Read once per mount: a mounted + // host never changes ancestors. + const surface = useRef(null); // Reads refs only, so every render's copy does the same thing; the loop // keeps the one from the render that enabled it. @@ -62,12 +66,14 @@ export function BugseeSecure(props: BugseeSecureProps): ReactElement { return; } try { + surface.current ??= surfaceOfInstance(view); + const measuredIn = surface.current; view.measureInWindow((x, y, width, height) => { if (!active.current) { return; } try { - setOwnerRectangles(token, 0, [{ x, y, width, height }], surfaceOf(view)); + setOwnerRectangles(token, 0, [{ x, y, width, height }], measuredIn); } catch (error) { failed(error); } @@ -89,6 +95,7 @@ export function BugseeSecure(props: BugseeSecureProps): ReactElement { const removeMeasurer = addMeasurer(measure); return () => { active.current = false; + surface.current = null; removeMeasurer(); clearOwner(token); }; @@ -103,19 +110,3 @@ export function BugseeSecure(props: BugseeSecureProps): ReactElement { return ; } - -/** - * The surface key native resolves for the React root holding `view`; the - * main surface when the view has no tag or native answers something that is - * not a key. Throws whatever the lookup throws: the caller treats that as a - * failed measurement and keeps the last rectangle. - */ -function surfaceOf(view: object): number { - const tag = (view as { __nativeTag?: unknown }).__nativeTag; - // Number.isFinite never coerces: a string or a missing tag is not finite. - if (!Number.isFinite(tag)) { - return MAIN_SURFACE; - } - const surface: unknown = NativeBugsee.secureSurfaceKey(tag as number); - return Number.isInteger(surface) ? (surface as number) : MAIN_SURFACE; -} diff --git a/packages/react-native/src/secure/__tests__/BugseeSecure.test.tsx b/packages/react-native/src/secure/__tests__/BugseeSecure.test.tsx index dfe995ae..3e76b102 100644 --- a/packages/react-native/src/secure/__tests__/BugseeSecure.test.tsx +++ b/packages/react-native/src/secure/__tests__/BugseeSecure.test.tsx @@ -282,10 +282,43 @@ describe('', () => { }); }); -// Fabric measureInWindow is relative to the React root that holds the view; -// a is its own root. The rectangle goes out under that root's surface -// key, which native resolves from the view's tag. +// Fabric measureInWindow is relative to the React root that holds the view, +// and a is its own root. The rectangle goes out under the surface of +// the nearest Modal host above the view, read off the fiber tree once per +// mount, with no native call. describe(' surfaces', () => { + const MODAL_TAG = 56; + /** Host fibers handed to RendererProxy, so a test can count the reads. */ + let tagReads: unknown[]; + + beforeEach(() => { + tagReads = []; + jest.doMock('react-native/Libraries/ReactNative/RendererProxy', () => ({ + getPublicInstanceFromInternalInstanceHandle: (fiber: { tagForTest?: unknown }) => { + tagReads.push(fiber); + return { __nativeTag: fiber.tagForTest }; + }, + })); + }); + + afterEach(() => { + jest.dontMock('react-native/Libraries/ReactNative/RendererProxy'); + }); + + /** A host fiber for the view, under `ancestors` (nearest first). */ + function fiberUnder(...ancestors: Array>): Record { + const view: Record = { tag: 5, type: 'RCTView', return: null }; + let child = view; + for (const ancestor of ancestors) { + const parent = { ...ancestor, return: null }; + child.return = parent; + child = parent; + } + return view; + } + + const modalHost = (tag: unknown = MODAL_TAG) => ({ tag: 5, type: 'RCTModalHostView', tagForTest: tag }); + function renderWithNode(element: ReactElement, node: Record): ReactTestRenderer { let renderer: ReactTestRenderer | undefined; act(() => { @@ -298,75 +331,83 @@ describe(' surfaces', () => { return renderer as ReactTestRenderer; } - it('publishes on the surface native resolves for its tag', () => { - native.secureSurfaceKey.mockReturnValue(42); - - renderWithNode(, { __nativeTag: 77 }); + it('publishes on the surface of the Modal it is in, asking native nothing', () => { + renderWithNode(, { + __internalInstanceHandle: fiberUnder({ tag: 5, type: 'RCTView' }, modalHost(), { tag: 3, type: null }), + }); - expect(native.secureSurfaceKey).toHaveBeenCalledWith(77); - expect(native.setSecureRectanglesOnSurface).toHaveBeenLastCalledWith(0, 42, MEASURED); + expect(native.setSecureRectanglesOnSurface).toHaveBeenLastCalledWith(0, MODAL_TAG, MEASURED); expect(native.setSecureRectangles).not.toHaveBeenCalled(); + expect(native.secureSurfaceOrigin).not.toHaveBeenCalled(); }); - it('a tag that resolves to the main surface publishes there', () => { - native.secureSurfaceKey.mockReturnValue(0); - - renderWithNode(, { __nativeTag: 77 }); + it('a view outside every Modal publishes on the main surface', () => { + renderWithNode(, { + __internalInstanceHandle: fiberUnder({ tag: 5, type: 'RCTView' }, { tag: 3, type: null }), + }); expect(native.setSecureRectangles).toHaveBeenLastCalledWith(0, MEASURED); expect(native.setSecureRectanglesOnSurface).not.toHaveBeenCalled(); }); - it('removes its rectangle from that surface on unmount', () => { - native.secureSurfaceKey.mockReturnValue(42); - const renderer = renderWithNode(, { __nativeTag: 77 }); + it('a view with no fiber publishes on the main surface', () => { + renderWithNode(, {}); - act(() => renderer.unmount()); + expect(native.setSecureRectangles).toHaveBeenLastCalledWith(0, MEASURED); + }); + + // Never the main surface: native serves a surface whose origin it cannot + // find as the whole display. + it('a Modal whose tag cannot be read publishes on the unknown-Modal surface', () => { + renderWithNode(, { __internalInstanceHandle: fiberUnder(modalHost(null)) }); - expect(native.setSecureRectanglesOnSurface).toHaveBeenLastCalledWith(0, 42, []); + expect(native.setSecureRectanglesOnSurface).toHaveBeenLastCalledWith(0, -1, MEASURED); + expect(native.setSecureRectangles).not.toHaveBeenCalled(); }); - it.each([ - ['no tag', {}], - ['a NaN tag', { __nativeTag: Number.NaN }], - ['an infinite tag', { __nativeTag: Number.POSITIVE_INFINITY }], - ['a string tag', { __nativeTag: '77' }], - ])('%s asks nothing and publishes on the main surface', (_name, node) => { - renderWithNode(, node); + it('reads its surface once per mount, however often it measures', () => { + renderWithNode(, { __internalInstanceHandle: fiberUnder(modalHost()) }); - expect(native.secureSurfaceKey).not.toHaveBeenCalled(); - expect(native.setSecureRectangles).toHaveBeenLastCalledWith(0, MEASURED); + tick(5); + + expect(tagReads).toHaveLength(1); + expect(measureInWindow.mock.calls.length).toBeGreaterThan(5); }); - it.each([ - ['NaN', Number.NaN], - ['a fraction', 1.5], - ['a non-number', '42'], - ['undefined', undefined], - ])('a surface key that is %s publishes on the main surface', (_name, key) => { - native.secureSurfaceKey.mockReturnValue(key as never); + it('reads it again after being re-enabled', () => { + const node = { __internalInstanceHandle: fiberUnder(modalHost()) }; + const renderer = renderWithNode(, node); - renderWithNode(, { __nativeTag: 77 }); + update(renderer, ); + update(renderer, ); - expect(native.setSecureRectangles).toHaveBeenLastCalledWith(0, MEASURED); - expect(native.setSecureRectanglesOnSurface).not.toHaveBeenCalled(); + expect(tagReads).toHaveLength(2); }); - // Same as any failed measurement: the last good rectangle stands. - it('a throwing surface lookup keeps the last rectangle', () => { - native.secureSurfaceKey.mockReturnValue(42); - renderWithNode(, { __nativeTag: 77 }); - const failure = new Error('no root'); - native.secureSurfaceKey.mockImplementation(() => { - throw failure; + it('removes its rectangle from that surface on unmount', () => { + const renderer = renderWithNode(, { __internalInstanceHandle: fiberUnder(modalHost()) }); + + act(() => renderer.unmount()); + + expect(native.setSecureRectanglesOnSurface).toHaveBeenLastCalledWith(0, MODAL_TAG, []); + }); + + // Same as any failed measurement: nothing is published, and the next + // measurement tries again. + it('a fiber tree that throws while it is read publishes nothing and warns once', () => { + const fiber = { tag: 5, type: 'RCTView' }; + const failure = new Error('torn fiber'); + Object.defineProperty(fiber, 'return', { + get(): never { + throw failure; + }, }); - native.setSecureRectanglesOnSurface.mockClear(); - measureInWindow.mockImplementation(measuresAt(10, 70, 30, 40)); + renderWithNode(, { __internalInstanceHandle: fiber }); tick(2); - expect(native.setSecureRectanglesOnSurface).not.toHaveBeenCalled(); expect(native.setSecureRectangles).not.toHaveBeenCalled(); + expect(native.setSecureRectanglesOnSurface).not.toHaveBeenCalled(); expect(warn).toHaveBeenCalledTimes(1); expect(warn).toHaveBeenCalledWith('[Bugsee] BugseeSecure could not measure', failure); }); diff --git a/packages/react-native/src/secure/__tests__/surface.test.ts b/packages/react-native/src/secure/__tests__/surface.test.ts new file mode 100644 index 00000000..6c77eef1 --- /dev/null +++ b/packages/react-native/src/secure/__tests__/surface.test.ts @@ -0,0 +1,145 @@ +/** + * Which React surface a view is measured in, read off the fiber tree. A + * wrong answer puts a secure rectangle on the wrong origin, so every test + * here is about the surface a real Modal tree must resolve to. + */ +import { FiberTag } from '../../viewtree/fiber'; +import type { FiberLike } from '../../viewtree/fiber'; +import type * as SurfaceModule from '../surface'; +import { + MAIN_SURFACE, + MODAL_HOST_TYPE, + UNKNOWN_MODAL_SURFACE, + nativeTagOfFiber, + surfaceOfFiber, + surfaceOfInstance, +} from '../surface'; + +const RENDERER_PROXY = 'react-native/Libraries/ReactNative/RendererProxy'; + +function fiber(tag: number, type: unknown, parent: FiberLike | null = null, nativeTag?: number): FiberLike { + return { + tag, + type, + child: null, + sibling: null, + return: parent, + memoizedProps: { nativeTag }, + memoizedState: null, + stateNode: null, + } as FiberLike; +} + +/** Reads the test's tag off `memoizedProps`, in place of the renderer. */ +const tagOf = (f: FiberLike): number | null => + (f.memoizedProps as { nativeTag?: number }).nativeTag ?? null; + +const root = () => fiber(FiberTag.HostRoot, null); +const modal = (parent: FiberLike, nativeTag?: number) => + fiber(FiberTag.HostComponent, MODAL_HOST_TYPE, parent, nativeTag); +const view = (parent: FiberLike) => fiber(FiberTag.HostComponent, 'RCTView', parent); + +describe('surfaceOfFiber', () => { + it('is the main surface outside every Modal', () => { + expect(surfaceOfFiber(view(view(root())), tagOf)).toBe(MAIN_SURFACE); + }); + + it("is the Modal host's tag inside a Modal, through composites and views", () => { + const composite = fiber(FiberTag.FunctionComponent, () => null, modal(view(root()), 56)); + expect(surfaceOfFiber(view(view(composite)), tagOf)).toBe(56); + }); + + it('is the nearest Modal for a Modal inside a Modal', () => { + const inner = modal(view(modal(root(), 56)), 78); + expect(surfaceOfFiber(view(inner), tagOf)).toBe(78); + }); + + // The Modal host itself is laid out in its parent's surface. + it('does not count the fiber itself', () => { + expect(surfaceOfFiber(modal(view(root()), 56), tagOf)).toBe(MAIN_SURFACE); + }); + + it('ignores a non-host fiber whose type happens to be the Modal host name', () => { + const lookalike = fiber(FiberTag.FunctionComponent, MODAL_HOST_TYPE, root(), 56); + expect(surfaceOfFiber(view(lookalike), tagOf)).toBe(MAIN_SURFACE); + }); + + it('is the unknown-Modal surface when the Modal host has no tag, never the main one', () => { + expect(surfaceOfFiber(view(modal(root())), tagOf)).toBe(UNKNOWN_MODAL_SURFACE); + expect(UNKNOWN_MODAL_SURFACE).not.toBe(MAIN_SURFACE); + }); + + it('stops on a cycle instead of spinning', () => { + const looped = view(root()); + looped.return = looped; + expect(surfaceOfFiber(view(looped), tagOf)).toBe(MAIN_SURFACE); + }); + + it('reads the tag through the renderer by default', () => { + jest.resetModules(); + jest.doMock(RENDERER_PROXY, () => ({ + getPublicInstanceFromInternalInstanceHandle: () => ({ __nativeTag: 91 }), + })); + const fresh = require('../surface') as typeof SurfaceModule; + expect(fresh.surfaceOfFiber(view(modal(root())))).toBe(91); + jest.dontMock(RENDERER_PROXY); + }); +}); + +describe('nativeTagOfFiber', () => { + afterEach(() => { + jest.dontMock(RENDERER_PROXY); + }); + + function withInstance(instance: unknown | (() => never)): typeof SurfaceModule { + jest.resetModules(); + jest.doMock(RENDERER_PROXY, () => ({ + getPublicInstanceFromInternalInstanceHandle: () => + typeof instance === 'function' ? (instance as () => never)() : instance, + })); + return require('../surface') as typeof SurfaceModule; + } + + it("reads the public instance's tag", () => { + expect(withInstance({ __nativeTag: 56 }).nativeTagOfFiber(root())).toBe(56); + }); + + it.each([ + ['no instance', null], + ['no tag', {}], + ['a NaN tag', { __nativeTag: Number.NaN }], + ['a string tag', { __nativeTag: '56' }], + ])('is null for %s', (_name, instance) => { + expect(withInstance(instance).nativeTagOfFiber(root())).toBeNull(); + }); + + it('is null when the renderer throws', () => { + expect( + withInstance(() => { + throw new Error('unmounted'); + }).nativeTagOfFiber(root()), + ).toBeNull(); + }); + + it('is exported for the walk', () => { + expect(typeof nativeTagOfFiber).toBe('function'); + }); +}); + +describe('surfaceOfInstance', () => { + it('is the main surface for an instance with no fiber', () => { + expect(surfaceOfInstance({})).toBe(MAIN_SURFACE); + expect(surfaceOfInstance({ __internalInstanceHandle: null })).toBe(MAIN_SURFACE); + expect(surfaceOfInstance({ __internalInstanceHandle: 7 })).toBe(MAIN_SURFACE); + }); + + it("is the surface of the instance's fiber", () => { + jest.resetModules(); + jest.doMock(RENDERER_PROXY, () => ({ + getPublicInstanceFromInternalInstanceHandle: () => ({ __nativeTag: 56 }), + })); + const fresh = require('../surface') as typeof SurfaceModule; + expect(fresh.surfaceOfInstance({ __internalInstanceHandle: view(modal(root())) })).toBe(56); + jest.dontMock(RENDERER_PROXY); + }); +}); diff --git a/packages/react-native/src/secure/registry.ts b/packages/react-native/src/secure/registry.ts index 4a4d5b97..7c9baf8e 100644 --- a/packages/react-native/src/secure/registry.ts +++ b/packages/react-native/src/secure/registry.ts @@ -1,6 +1,7 @@ import NativeBugsee from '../NativeBugsee'; import { flattenSecureRectangles } from './rectangles'; import type { SecureRectangle } from './rectangles'; +import { MAIN_SURFACE } from './surface'; import { secureRectangleScale } from './unit'; /** @@ -13,8 +14,7 @@ import { secureRectangleScale } from './unit'; */ export type SecureOwner = object | `manual:${number}`; -/** The activity React root's surface; matches native `MAIN_SURFACE`. */ -export const MAIN_SURFACE = 0; +export { MAIN_SURFACE }; interface OwnerEntry { readonly display: number; diff --git a/packages/react-native/src/secure/surface.ts b/packages/react-native/src/secure/surface.ts new file mode 100644 index 00000000..704d2d61 --- /dev/null +++ b/packages/react-native/src/secure/surface.ts @@ -0,0 +1,83 @@ +import { FiberTag } from '../viewtree/fiber'; +import type { FiberLike } from '../viewtree/fiber'; + +/** + * Which React surface a view is measured in, read off the fiber tree with + * no native call. + * + * Fabric `measureInWindow` stops at the nearest `RootNodeKind` ancestor. A + * `` is one (`ModalHostViewShadowNode`), and its host fiber is the + * `RCTModalHostView` component, so a view inside a Modal is measured from + * that Modal's content and everything else from the app's own root. A + * surface is named by the Modal host's React tag (Android also gives the + * Modal's dialog root that id); the app's root is {@link MAIN_SURFACE}. + * Native translates each surface's rectangles by that surface's own origin. + */ + +/** The app's own React root; matches native `MAIN_SURFACE`. */ +export const MAIN_SURFACE = 0; + +/** + * A view known to be inside a `` whose host's tag cannot be read. + * Never the main surface: native serves a surface whose origin it cannot + * find as the whole display. + */ +export const UNKNOWN_MODAL_SURFACE = -1; + +/** The host component type of a `` (`RCTModalHostViewNativeComponent`). */ +export const MODAL_HOST_TYPE = 'RCTModalHostView'; + +/** A cycle in a malformed tree must not spin forever. */ +const MAX_ANCESTORS = 10_000; + +/** + * The native tag of a host fiber, via its public instance (Fabric's carry + * `__nativeTag`), or `null` when it has none. + */ +export function nativeTagOfFiber(fiber: FiberLike): number | null { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports -- lazy, like the view tree's measure: only a real renderer has it. + const rendererProxy = require('react-native/Libraries/ReactNative/RendererProxy') as { + getPublicInstanceFromInternalInstanceHandle: (fiber: unknown) => unknown; + }; + const publicInstance = rendererProxy.getPublicInstanceFromInternalInstanceHandle(fiber); + const tag = (publicInstance as { __nativeTag?: unknown } | null)?.__nativeTag; + // Number.isFinite never coerces: a string or a missing tag is not finite. + return Number.isFinite(tag) ? (tag as number) : null; + } catch { + return null; + } +} + +/** + * The surface `fiber` is measured in: the tag of the nearest `` host + * above it, {@link UNKNOWN_MODAL_SURFACE} when that host has no tag, or + * {@link MAIN_SURFACE} when there is no Modal above it. `fiber` itself is + * not considered: a Modal host is measured in its parent's surface. + */ +export function surfaceOfFiber( + fiber: FiberLike, + tagOf: (fiber: FiberLike) => number | null = nativeTagOfFiber, +): number { + let current = fiber.return; + for (let steps = 0; current != null && steps < MAX_ANCESTORS; steps += 1) { + if (current.tag === FiberTag.HostComponent && current.type === MODAL_HOST_TYPE) { + return tagOf(current) ?? UNKNOWN_MODAL_SURFACE; + } + current = current.return; + } + return MAIN_SURFACE; +} + +/** + * The surface of a mounted host's public instance (Fabric's carries its + * fiber as `__internalInstanceHandle`). {@link MAIN_SURFACE} when the + * instance has no fiber, which only a non-Fabric renderer produces. + */ +export function surfaceOfInstance(instance: object): number { + const fiber = (instance as { __internalInstanceHandle?: unknown }).__internalInstanceHandle; + if (fiber === null || typeof fiber !== 'object') { + return MAIN_SURFACE; + } + return surfaceOfFiber(fiber as FiberLike); +} diff --git a/packages/react-native/src/viewtree/__tests__/requests.test.ts b/packages/react-native/src/viewtree/__tests__/requests.test.ts index 2fc1b341..481203f8 100644 --- a/packages/react-native/src/viewtree/__tests__/requests.test.ts +++ b/packages/react-native/src/viewtree/__tests__/requests.test.ts @@ -121,13 +121,13 @@ describe('a vh request', () => { }); }); -// A is its own React root: the walk asks native for the origin of the -// root that hosts each measured node, by that node's native tag. +// A is its own React surface: the walk asks native for the origin of +// a Modal by its host's native tag, once per Modal per walk. describe('the walk env, per surface', () => { const RENDERER_PROXY = 'react-native/Libraries/ReactNative/RendererProxy'; function envOf(): { - originForNativeTag: (tag: number) => { x: number; y: number } | null; + originForSurface: (surface: number) => { x: number; y: number } | null; nativeTagOf: (fiber: FiberLike) => number | null; } { const { instance } = anchorUnderNewRoot(); @@ -143,7 +143,7 @@ describe('the walk env, per surface', () => { it("resolves a tag's origin through secureSurfaceOrigin", () => { native.secureSurfaceOrigin.mockReturnValue([40, 200.5]); - expect(envOf().originForNativeTag(77)).toEqual({ x: 40, y: 200.5 }); + expect(envOf().originForSurface(77)).toEqual({ x: 40, y: 200.5 }); expect(native.secureSurfaceOrigin).toHaveBeenCalledWith(77); }); @@ -157,7 +157,7 @@ describe('the walk env, per surface', () => { ])('knows no origin when native answers %s', (_name, answer) => { native.secureSurfaceOrigin.mockReturnValue(answer as never); - expect(envOf().originForNativeTag(77)).toBeNull(); + expect(envOf().originForSurface(77)).toBeNull(); }); it('knows no origin when native throws', () => { @@ -165,7 +165,7 @@ describe('the walk env, per surface', () => { throw new Error('no root'); }); - expect(envOf().originForNativeTag(77)).toBeNull(); + expect(envOf().originForSurface(77)).toBeNull(); }); it("reads a host fiber's tag off its public instance", () => { @@ -611,6 +611,7 @@ describe('the real walk env', () => { platform: 'ios' | 'android', origin: { x: number; y: number } = { x: 0, y: 0 }, arrange: () => void = () => undefined, + inModal = false, ): Node { jest.unmock('../walk'); jest.resetModules(); @@ -633,8 +634,11 @@ describe('the real walk env', () => { const appHost = makeFiber({ type: 'RootView', memoizedProps: { testID: 'app-root' }, - stateNode: { rect: { x: 1, y: 2, width: 10, height: 20 }, tag: 11 }, + stateNode: { rect: { x: 1, y: 2, width: 10, height: 20 } }, }); + // Unmeasurable, like a real Modal host on Android: its children are + // promoted, carrying the Modal's origin. + const modalHost = makeFiber({ type: 'RCTModalHostView', stateNode: { tag: 56 }, child: appHost }); const secureHost = makeFiber({ type: 'SecretView', memoizedProps: { testID: 'nope' }, @@ -679,8 +683,14 @@ describe('the real walk env', () => { secureBoundary.sibling = secureBoundaryViaElementType; secureBoundaryViaElementType.sibling = anchor; - const wrapFiber = makeFiber({ tag: FiberTag.FunctionComponent, type: WrapMarker, child: appHost }); - appHost.return = wrapFiber; + const firstChild = inModal ? modalHost : appHost; + if (inModal) { + modalHost.sibling = appHost.sibling; + appHost.sibling = null; + appHost.return = modalHost; + } + const wrapFiber = makeFiber({ tag: FiberTag.FunctionComponent, type: WrapMarker, child: firstChild }); + firstChild.return = wrapFiber; secureBoundary.return = wrapFiber; secureBoundaryViaElementType.return = wrapFiber; anchor.return = wrapFiber; @@ -747,14 +757,25 @@ describe('the real walk env', () => { expect(appNode?.bounds).toEqual([103, 206, 30, 60]); }); - it('places a node by the origin native resolves for its own React root', () => { - const tree = runRealWalk('android', { x: 100, y: 200 }, () => { - native.secureSurfaceOrigin.mockImplementation((tag: number) => (tag === 11 ? [40, 60] : [])); - }); + it("places a node inside a Modal by the Modal's origin, asking once", () => { + const tree = runRealWalk( + 'android', + { x: 100, y: 200 }, + () => { + native.secureSurfaceOrigin.mockImplementation((tag: number) => (tag === 56 ? [40, 60] : [])); + }, + true, + ); const appNode = flatten(tree).find((n) => n.class_name === 'RootView'); // Scaled by 3 as above, then moved by (40, 60), not the request's (100, 200). expect(appNode?.bounds).toEqual([43, 66, 30, 60]); - expect(native.secureSurfaceOrigin).toHaveBeenCalledWith(11); + expect(native.secureSurfaceOrigin.mock.calls).toEqual([[56]]); + }); + + it('asks native nothing for a tree without a Modal', () => { + runRealWalk('android', { x: 100, y: 200 }); + + expect(native.secureSurfaceOrigin).not.toHaveBeenCalled(); }); }); diff --git a/packages/react-native/src/viewtree/__tests__/walk.test.ts b/packages/react-native/src/viewtree/__tests__/walk.test.ts index 4b99d98f..06c5cdf9 100644 --- a/packages/react-native/src/viewtree/__tests__/walk.test.ts +++ b/packages/react-native/src/viewtree/__tests__/walk.test.ts @@ -779,74 +779,136 @@ describe('buildViewTree', () => { expect(node?.bounds).toEqual([11.13, 22.67, 5.56, 6.45]); }); - // A is its own React root (an Android Dialog): measureInWindow is - // relative to it, so its nodes take that root's origin, not the request's. - describe('a node on another React surface', () => { - const tagged = (tag: number) => (fiber: FiberLike): number | null => - (fiber.memoizedProps as { tag?: number } | null)?.tag === tag ? tag : null; - const sheet = (): FiberSpec => host('Sheet', { x: 10, y: 20, width: 5, height: 5 }, [], { tag: 77 }); - - it('Android: takes the origin native resolves for its tag', () => { - const originForNativeTag = jest.fn((tag: number) => (tag === 77 ? { x: 40, y: 200 } : null)); + // A is its own React surface: measureInWindow inside it is relative + // to the Modal's content, so its subtree takes that surface's origin. The + // origin is asked once per Modal per walk, never per node. + describe('a subtree inside a ', () => { + const MODAL_RECT = { x: 0, y: 0, width: 360, height: 800 }; + const tagged = (fiber: FiberLike): number | null => + (fiber.memoizedProps as { tag?: number } | null)?.tag ?? null; + const modal = (tag: number | undefined, children: FiberSpec[], rect: WindowRect | null = MODAL_RECT): FiberSpec => + host('RCTModalHostView', rect, children, tag === undefined ? {} : { tag }); + const box = (name: string, y = 20): FiberSpec => host(name, { x: 10, y, width: 5, height: 5 }); + + function nodes(tree: ManagedNode | null): Map { + return new Map(flatten(tree as ManagedNode).map((n) => [n.class_name, n])); + } + + it('Android: places the subtree by the Modal surface origin, the host by the enclosing one', () => { + const originForSurface = jest.fn((tag: number) => (tag === 56 ? { x: 40, y: 200 } : null)); + const tree = buildViewTree( + [fiberRoot(fragment([box('Main'), modal(56, [box('Sheet'), box('Sheet2', 30)])]))], + makeEnv({ platform: 'android', scale: 2, originX: 0, originY: 63, nativeTagOf: tagged, originForSurface }), + ); + + const byName = nodes(tree); + expect(byName.get('Main')?.bounds).toEqual([20, 103, 10, 10]); + expect(byName.get('RCTModalHostView')?.bounds).toEqual([0, 63, 720, 1600]); + expect(byName.get('Sheet')?.bounds).toEqual([60, 240, 10, 10]); + expect(byName.get('Sheet2')?.bounds).toEqual([60, 260, 10, 10]); + expect(originForSurface).toHaveBeenCalledTimes(1); + expect(originForSurface).toHaveBeenCalledWith(56); + }); + + it('iOS: places the subtree by the Modal surface origin', () => { const tree = buildViewTree( - [fiberRoot(fragment([host('Main', { x: 10, y: 20, width: 5, height: 5 }), sheet()]))], - makeEnv({ platform: 'android', scale: 2, originX: 0, originY: 63, nativeTagOf: tagged(77), originForNativeTag }), + [fiberRoot(modal(56, [box('Sheet')]))], + makeEnv({ platform: 'ios', originX: 1, originY: 2, nativeTagOf: tagged, originForSurface: () => ({ x: 3.5, y: 4 }) }), + ); + + expect(nodes(tree).get('Sheet')?.bounds).toEqual([13.5, 24, 5, 5]); + }); + + it('asks native nothing for a tree without a Modal', () => { + const originForSurface = jest.fn(() => ({ x: 40, y: 200 })); + buildViewTree( + [fiberRoot(fragment([box('A'), host('B', RECT, [box('C')], { tag: 9 })]))], + makeEnv({ platform: 'android', scale: 1, nativeTagOf: tagged, originForSurface }), ); - const [main, modal] = tree?.subitems?.[0]?.subitems ?? []; - expect(main?.bounds).toEqual([20, 103, 10, 10]); - expect(modal?.bounds).toEqual([60, 240, 10, 10]); - expect(originForNativeTag).toHaveBeenCalledTimes(1); - expect(originForNativeTag).toHaveBeenCalledWith(77); + expect(originForSurface).not.toHaveBeenCalled(); }); - it('iOS: takes the origin native resolves for its tag', () => { + it('asks once per Modal, and again for a different Modal', () => { + const originForSurface = jest.fn((tag: number) => ({ x: tag, y: 0 })); const tree = buildViewTree( - [fiberRoot(sheet())], - makeEnv({ platform: 'ios', originX: 1, originY: 2, nativeTagOf: tagged(77), originForNativeTag: () => ({ x: 3.5, y: 4 }) }), + [fiberRoot(fragment([modal(56, [box('First')]), modal(78, [box('Second')])]))], + makeEnv({ platform: 'android', scale: 1, nativeTagOf: tagged, originForSurface }), + ); + + expect(originForSurface.mock.calls).toEqual([[56], [78]]); + expect(nodes(tree).get('First')?.bounds).toEqual([66, 20, 5, 5]); + expect(nodes(tree).get('Second')?.bounds).toEqual([88, 20, 5, 5]); + }); + + it('asks once for one Modal reached from two roots', () => { + const originForSurface = jest.fn(() => ({ x: 40, y: 200 })); + buildViewTree( + [fiberRoot(modal(56, [box('A')])), fiberRoot(modal(56, [box('B')]))], + makeEnv({ platform: 'android', scale: 1, nativeTagOf: tagged, originForSurface }), ); - expect(tree?.subitems?.[0]?.subitems?.[0]?.bounds).toEqual([13.5, 24, 5, 5]); + expect(originForSurface).toHaveBeenCalledTimes(1); }); - it('falls back to the request origin when native knows no origin for the tag', () => { + it('a Modal inside a Modal takes the inner origin', () => { const tree = buildViewTree( - [fiberRoot(sheet())], - makeEnv({ platform: 'android', scale: 1, originX: 7, originY: 9, nativeTagOf: tagged(77), originForNativeTag: () => null }), + [fiberRoot(modal(56, [box('Outer'), modal(78, [box('Inner')])]))], + makeEnv({ + platform: 'android', + scale: 1, + nativeTagOf: tagged, + originForSurface: (tag) => (tag === 56 ? { x: 100, y: 0 } : { x: 0, y: 300 }), + }), ); - expect(tree?.subitems?.[0]?.subitems?.[0]?.bounds).toEqual([17, 29, 5, 5]); + expect(nodes(tree).get('Outer')?.bounds).toEqual([110, 20, 5, 5]); + expect(nodes(tree).get('Inner')?.bounds).toEqual([10, 320, 5, 5]); }); - it('asks nothing for a node without a tag', () => { - const originForNativeTag = jest.fn(() => ({ x: 40, y: 200 })); + it('an unmeasurable Modal host still gives its children its origin', () => { const tree = buildViewTree( - [fiberRoot(sheet())], - makeEnv({ platform: 'android', scale: 1, originX: 7, originY: 9, nativeTagOf: () => null, originForNativeTag }), + [fiberRoot(modal(56, [box('Sheet')], null))], + makeEnv({ platform: 'android', scale: 1, nativeTagOf: tagged, originForSurface: () => ({ x: 40, y: 200 }) }), ); - expect(tree?.subitems?.[0]?.subitems?.[0]?.bounds).toEqual([17, 29, 5, 5]); - expect(originForNativeTag).not.toHaveBeenCalled(); + expect(nodes(tree).get('Sheet')?.bounds).toEqual([50, 220, 5, 5]); }); - it('uses the request origin when the env has no tag reader', () => { - const originForNativeTag = jest.fn(() => ({ x: 40, y: 200 })); + it('keeps the enclosing origin when native knows no origin for the Modal', () => { const tree = buildViewTree( - [fiberRoot(sheet())], - makeEnv({ platform: 'android', scale: 1, originX: 7, originY: 9, originForNativeTag }), + [fiberRoot(modal(56, [box('Sheet')]))], + makeEnv({ platform: 'android', scale: 1, originX: 7, originY: 9, nativeTagOf: tagged, originForSurface: () => null }), ); - expect(tree?.subitems?.[0]?.subitems?.[0]?.bounds).toEqual([17, 29, 5, 5]); - expect(originForNativeTag).not.toHaveBeenCalled(); + expect(nodes(tree).get('Sheet')?.bounds).toEqual([17, 29, 5, 5]); }); - it('uses the request origin when the env has no origin resolver', () => { + it('keeps the enclosing origin for a Modal host with no tag', () => { + const originForSurface = jest.fn(() => ({ x: 40, y: 200 })); const tree = buildViewTree( - [fiberRoot(sheet())], - makeEnv({ platform: 'android', scale: 1, originX: 7, originY: 9, nativeTagOf: tagged(77) }), + [fiberRoot(modal(undefined, [box('Sheet')]))], + makeEnv({ platform: 'android', scale: 1, originX: 7, originY: 9, nativeTagOf: tagged, originForSurface }), + ); + + expect(nodes(tree).get('Sheet')?.bounds).toEqual([17, 29, 5, 5]); + expect(originForSurface).not.toHaveBeenCalled(); + }); + + it('keeps the enclosing origin when the env cannot read tags or origins', () => { + const originForSurface = jest.fn(() => ({ x: 40, y: 200 })); + const noTags = buildViewTree( + [fiberRoot(modal(56, [box('Sheet')]))], + makeEnv({ platform: 'android', scale: 1, originX: 7, originY: 9, originForSurface }), + ); + const noOrigins = buildViewTree( + [fiberRoot(modal(56, [box('Sheet')]))], + makeEnv({ platform: 'android', scale: 1, originX: 7, originY: 9, nativeTagOf: tagged }), ); - expect(tree?.subitems?.[0]?.subitems?.[0]?.bounds).toEqual([17, 29, 5, 5]); + expect(nodes(noTags).get('Sheet')?.bounds).toEqual([17, 29, 5, 5]); + expect(nodes(noOrigins).get('Sheet')?.bounds).toEqual([17, 29, 5, 5]); + expect(originForSurface).not.toHaveBeenCalled(); }); }); diff --git a/packages/react-native/src/viewtree/requests.ts b/packages/react-native/src/viewtree/requests.ts index 264d93f4..902b84a1 100644 --- a/packages/react-native/src/viewtree/requests.ts +++ b/packages/react-native/src/viewtree/requests.ts @@ -16,6 +16,7 @@ import { Platform } from 'react-native'; import NativeBugsee from '../NativeBugsee'; import { RootErrorReporter } from '../exceptions/RootErrorReporter'; import { BugseeSecure } from '../secure/BugseeSecure'; +import { nativeTagOfFiber } from '../secure/surface'; import { secureRectangleScale } from '../secure/unit'; import { VH_ANCHOR_NATIVE_ID } from './constants'; import { errorName } from '../errorName'; @@ -203,15 +204,15 @@ function buildEnv(event: DataRequestEvent): WalkEnv { originX: event.originX, originY: event.originY, nativeTagOf: nativeTagOfFiber, - originForNativeTag: (tag) => { + originForSurface: (surface) => { try { - const origin: unknown = NativeBugsee.secureSurfaceOrigin(tag); + const origin: unknown = NativeBugsee.secureSurfaceOrigin(surface); // Number.isFinite never coerces, so a missing element fails it too. if (Array.isArray(origin) && Number.isFinite(origin[0]) && Number.isFinite(origin[1])) { return { x: origin[0] as number, y: origin[1] as number }; } } catch { - // Fall back to the activity / main origin from the request. + // Keep the enclosing origin for that Modal's subtree. } return null; }, @@ -219,24 +220,6 @@ function buildEnv(event: DataRequestEvent): WalkEnv { }; } -/** - * Fabric public instances carry `__nativeTag`. Used to resolve the hosting - * React root's display origin for a measured host (Modal vs activity). - */ -function nativeTagOfFiber(fiber: FiberLike): number | null { - try { - // eslint-disable-next-line @typescript-eslint/no-require-imports -- same lazy require as measureHostFiber. - const rendererProxy = require('react-native/Libraries/ReactNative/RendererProxy') as { - getPublicInstanceFromInternalInstanceHandle: (fiber: unknown) => unknown; - }; - const publicInstance = rendererProxy.getPublicInstanceFromInternalInstanceHandle(fiber); - const tag = (publicInstance as { __nativeTag?: unknown } | null)?.__nativeTag; - return Number.isFinite(tag) ? (tag as number) : null; - } catch { - return null; - } -} - /** * The JSON text to reply with for a `'vh'` request that has at least one root * to walk, or `null` for every outcome that isn't a real tree. diff --git a/packages/react-native/src/viewtree/walk.ts b/packages/react-native/src/viewtree/walk.ts index 7b779417..f04741a8 100644 --- a/packages/react-native/src/viewtree/walk.ts +++ b/packages/react-native/src/viewtree/walk.ts @@ -20,6 +20,7 @@ * away, and `.child`/`.sibling`/`.return` being `undefined` or some other * non-object value is tolerated the same as `null` throughout. */ +import { MODAL_HOST_TYPE } from '../secure/surface'; import { FiberTag } from './fiber'; import type { FiberLike, WindowRect } from './fiber'; @@ -67,11 +68,13 @@ export interface WalkEnv { originX: number; originY: number; /** - * Display origin of the React root that hosts `nativeTag`, when known. - * A ``'s dialog root must not inherit the activity root's origin. - * Falls back to {@link originX}/{@link originY} when absent or null. + * Display origin of the `` whose host has React tag `surface`, when + * known. `measureInWindow` inside a Modal is relative to the Modal's own + * content, so its subtree must not take the request's origin. Asked at + * most once per Modal per walk; a `null` answer keeps the enclosing + * origin for that subtree. */ - originForNativeTag?: (nativeTag: number) => { x: number; y: number } | null; + originForSurface?: (surface: number) => Origin | null; /** Native tag of a host fiber's public instance, when measurable. */ nativeTagOf?: (fiber: FiberLike) => number | null; /** ms; the budget is measured from this function's first call. */ @@ -80,6 +83,12 @@ export interface WalkEnv { type Bounds = [number, number, number, number]; +/** Where `measureInWindow`'s (0, 0) sits, in the units `bounds` are in. */ +export interface Origin { + x: number; + y: number; +} + interface Ctx { env: WalkEnv; startedAt: number; @@ -91,6 +100,8 @@ interface Ctx { anyTruncated: boolean; /** Every fiber visited so far, across every root โ€” a repeat means a cycle, not a legitimate second visit (a well-formed tree never revisits a fiber). */ visited: Set; + /** Each `` surface's origin for this walk, asked of `originForSurface` once. */ + surfaceOrigins: Map; } /** The children collected for one fiber (or one fiber root), plus whether that list is known to be incomplete. */ @@ -100,7 +111,7 @@ interface Frame { } type Task = - | { kind: 'visit'; fiber: FiberLike; secure: boolean; depthRemaining: number; frame: Frame } + | { kind: 'visit'; fiber: FiberLike; secure: boolean; depthRemaining: number; frame: Frame; origin: Origin } | { kind: 'finishHost'; frame: Frame; @@ -147,19 +158,37 @@ function iosBounds(rect: WindowRect, env: WalkEnv, originX: number, originY: num ]; } -function originForFiber(fiber: FiberLike, env: WalkEnv): { x: number; y: number } { +/** + * The origin `fiber`'s children are measured from: a `` host starts a + * surface of its own (its subtree is relative to the Modal's content), every + * other fiber passes `origin` down unchanged. The Modal host itself is + * measured in its parent's surface, so its own bounds keep `origin`. + */ +function childOriginOf(fiber: FiberLike, origin: Origin, ctx: Ctx): Origin { + if (!isModalHost(fiber)) { + return origin; + } + const { env } = ctx; const tag = env.nativeTagOf?.(fiber) ?? null; - if (tag != null && env.originForNativeTag) { - const resolved = env.originForNativeTag(tag); - if (resolved != null) { - return resolved; - } + if (tag === null || env.originForSurface === undefined) { + return origin; + } + if (!ctx.surfaceOrigins.has(tag)) { + ctx.surfaceOrigins.set(tag, env.originForSurface(tag)); } - return { x: env.originX, y: env.originY }; + return ctx.surfaceOrigins.get(tag) ?? origin; } -function toBounds(rect: WindowRect, env: WalkEnv, fiber: FiberLike): Bounds { - const origin = originForFiber(fiber, env); +/** Whether `fiber` is a `` host. A throwing `.type` getter is not one (`safeHostClassName` names it). */ +function isModalHost(fiber: FiberLike): boolean { + try { + return fiber.type === MODAL_HOST_TYPE; + } catch { + return false; + } +} + +function toBounds(rect: WindowRect, env: WalkEnv, origin: Origin): Bounds { return env.platform === 'android' ? androidBounds(rect, env, origin.x, origin.y) : iosBounds(rect, env, origin.x, origin.y); @@ -491,14 +520,21 @@ function makeNode( } /** Pushes the work to flatten `fiber`'s children directly into `parentFrame` โ€” used for every "this fiber emits nothing of its own" case (transparent tags, the wrap component/anchor, a visible Offscreen/LegacyHidden, and an unmeasurable host promoting its children per the plan's "no measurable host" rule). */ -function pushFlatten(stack: Task[], fiber: FiberLike, secure: boolean, depthRemaining: number, parentFrame: Frame): void { +function pushFlatten( + stack: Task[], + fiber: FiberLike, + secure: boolean, + depthRemaining: number, + parentFrame: Frame, + origin: Origin, +): void { const child = linkOf(fiber.child); if (child === null) { return; } const frame: Frame = { nodes: [], truncated: false }; stack.push({ kind: 'finishFlatten', frame, parentFrame }); - stack.push({ kind: 'visit', fiber: child, secure, depthRemaining, frame }); + stack.push({ kind: 'visit', fiber: child, secure, depthRemaining, frame, origin }); } /** @@ -509,7 +545,10 @@ function pushFlatten(stack: Task[], fiber: FiberLike, secure: boolean, depthRema * JS call stack or loop forever. */ function runWalk(rootFiber: FiberLike, ctx: Ctx, outerFrame: Frame): void { - const stack: Task[] = [{ kind: 'visit', fiber: rootFiber, secure: false, depthRemaining: VH_MAX_DEPTH, frame: outerFrame }]; + const rootOrigin: Origin = { x: ctx.env.originX, y: ctx.env.originY }; + const stack: Task[] = [ + { kind: 'visit', fiber: rootFiber, secure: false, depthRemaining: VH_MAX_DEPTH, frame: outerFrame, origin: rootOrigin }, + ]; while (stack.length > 0) { const task = stack.pop() as Task; @@ -561,7 +600,7 @@ function runWalk(rootFiber: FiberLike, ctx: Ctx, outerFrame: Frame): void { if ((task as { kind: unknown }).kind !== 'visit') { throw new Error('unreachable view-tree task kind'); } - const { fiber, secure, depthRemaining, frame } = task; + const { fiber, secure, depthRemaining, frame, origin } = task; // N5 (fix round 2): everything from here on reads fields off `fiber` // itself โ€” `.tag`, `.sibling`, `.child`, `.type`, `.elementType`, @@ -607,7 +646,7 @@ function runWalk(rootFiber: FiberLike, ctx: Ctx, outerFrame: Frame): void { // outcome for anything this walk produces. const sibling = linkOf(fiber.sibling); if (sibling !== null && !ctx.stopped) { - stack.push({ kind: 'visit', fiber: sibling, secure, depthRemaining, frame }); + stack.push({ kind: 'visit', fiber: sibling, secure, depthRemaining, frame, origin }); } ctx.fiberVisits += 1; @@ -623,7 +662,7 @@ function runWalk(rootFiber: FiberLike, ctx: Ctx, outerFrame: Frame): void { const secureHere = secure || safeIsSecureBoundary(ctx.env, fiber); if (safeIsWrapper(ctx.env, fiber)) { - pushFlatten(stack, fiber, secureHere, depthRemaining, frame); + pushFlatten(stack, fiber, secureHere, depthRemaining, frame, origin); continue; } @@ -631,24 +670,25 @@ function runWalk(rootFiber: FiberLike, ctx: Ctx, outerFrame: Frame): void { if (isHidden(fiber)) { continue; } - pushFlatten(stack, fiber, secureHere, depthRemaining, frame); + pushFlatten(stack, fiber, secureHere, depthRemaining, frame, origin); continue; } const kind = classify(fiber.tag); if (kind === 'transparent') { - pushFlatten(stack, fiber, secureHere, depthRemaining, frame); + pushFlatten(stack, fiber, secureHere, depthRemaining, frame, origin); continue; } if (kind === 'host') { + const childOrigin = childOriginOf(fiber, origin, ctx); const rect = safeMeasure(ctx.env, fiber); if (rect === null) { // No measurable host here โ€” promote whatever children it has // (as if this fiber were transparent) rather than dropping a // subtree that might still have something to show underneath. - pushFlatten(stack, fiber, secureHere, depthRemaining, frame); + pushFlatten(stack, fiber, secureHere, depthRemaining, frame, childOrigin); continue; } @@ -657,14 +697,21 @@ function runWalk(rootFiber: FiberLike, ctx: Ctx, outerFrame: Frame): void { const child = linkOf(fiber.child); const atDepthLimit = child !== null && depthRemaining <= 1; const depthCut = atDepthLimit && hasVisibleContentChild(child as FiberLike); - const bounds = toBounds(rect, ctx.env, fiber); + const bounds = toBounds(rect, ctx.env, origin); const tagOpts = secureHere ? {} : tagOptions(fiber); const className = safeHostClassName(fiber); if (child !== null && !atDepthLimit) { const childFrame: Frame = { nodes: [], truncated: false }; stack.push({ kind: 'finishHost', frame: childFrame, parentFrame: frame, className, bounds, secureHere, tagOpts, depthCut: false }); - stack.push({ kind: 'visit', fiber: child, secure: secureHere, depthRemaining: depthRemaining - 1, frame: childFrame }); + stack.push({ + kind: 'visit', + fiber: child, + secure: secureHere, + depthRemaining: depthRemaining - 1, + frame: childFrame, + origin: childOrigin, + }); } else { stack.push({ kind: 'finishHost', @@ -691,7 +738,7 @@ function runWalk(rootFiber: FiberLike, ctx: Ctx, outerFrame: Frame): void { if (child !== null && !atDepthLimit) { const childFrame: Frame = { nodes: [], truncated: false }; stack.push({ kind: 'finishComposite', frame: childFrame, parentFrame: frame, className, secureHere }); - stack.push({ kind: 'visit', fiber: child, secure: secureHere, depthRemaining: depthRemaining - 1, frame: childFrame }); + stack.push({ kind: 'visit', fiber: child, secure: secureHere, depthRemaining: depthRemaining - 1, frame: childFrame, origin }); } else { stack.push({ kind: 'finishComposite', @@ -766,6 +813,7 @@ export function buildViewTree(roots: readonly { current: FiberLike }[], env: Wal stopped: false, anyTruncated: false, visited: new Set(), + surfaceOrigins: new Map(), }; const surfaces: ManagedNode[] = []; From 7895840655a17bb4b134609726b01befd63985b3 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Mon, 5 Oct 2026 11:39:07 +0500 Subject: [PATCH 06/19] test(e2e): find each secure view by its colour, and add the discriminating Modals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fix round 1 (C1, I1, I3). The secure-modal* scenarios paint the main secure view #FF00FF and the Modal's #00FFFF. Ground truth no longer comes from what the app measured: the device's own screenshot (adb screencap, simctl io screenshot) finds where each colour really is, the report's screenshot must be dark there, and it must not contain either colour anywhere (on the iPhone, which has no command-line screenshot, only the second check runs). New scenarios: secure-modal-translucent (Android; statusBarTranslucent, so with edge-to-edge off the dialog's origin is 0 while the activity root's is the status bar) and secure-modal-sheet (iOS; an opaque pageSheet, inset inside the window). The Android main-rectangle lookup matches the activity root's `kind=activity` origin line instead of `surface=0`, so it cannot be built from the Modal's origin (I3). ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --- examples/bare/App.tsx | 4 +- examples/bare/e2e/media.ts | 71 +++++ examples/bare/e2e/secure-modal.test.ts | 397 +++++++++++++++---------- examples/bare/scenarios/privacy.tsx | 64 ++-- 4 files changed, 355 insertions(+), 181 deletions(-) diff --git a/examples/bare/App.tsx b/examples/bare/App.tsx index 0ce8ca06..819664af 100644 --- a/examples/bare/App.tsx +++ b/examples/bare/App.tsx @@ -15,8 +15,8 @@ * is scenarios/secure.tsx, `attributes`/`attributes-persist` are * scenarios/attributes.ts, `e2e-native-smoke` / `native-crash-*` are * scenarios/native.ts, `blackout`, `blackout-prelaunch`, - * `secure-component`, `secure-modal` and `view-tree` are - * scenarios/privacy.tsx, and + * `secure-component`, `secure-modal`, `secure-modal-translucent`, + * `secure-modal-sheet` and `view-tree` are scenarios/privacy.tsx, and * `exc-*` are scenarios/exceptions.tsx, `rp-*` are * scenarios/reporting.ts, `log-filter` is scenarios/log-filter.ts, * `breadcrumb-filter` is scenarios/breadcrumb-filter.ts, diff --git a/examples/bare/e2e/media.ts b/examples/bare/e2e/media.ts index cac6a0da..05db0025 100644 --- a/examples/bare/e2e/media.ts +++ b/examples/bare/e2e/media.ts @@ -415,3 +415,74 @@ export function heldBlackout( `${BLACKOUT_MIN_DARK_S}s until a bright frame`, }; } + +/** An RGB colour, each channel 0-255. */ +export interface Colour { + readonly r: number; + readonly g: number; + readonly b: number; +} + +/** How far each channel may stray from a test colour: lossy screenshots shift it. */ +export const COLOUR_TOLERANCE = 48; + +/** `#RRGGBB` as a `Colour`. */ +export function colourOf(hex: string): Colour { + const match = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex); + if (match === null) { + throw new Error(`colourOf: not #RRGGBB: ${hex}`); + } + return { r: parseInt(match[1]!, 16), g: parseInt(match[2]!, 16), b: parseInt(match[3]!, 16) }; +} + +/** + * Every pixel of `file`'s first frame within `tolerance` of `colour` on all + * three channels: how many, and their bounding box in image pixels (`right` + * and `bottom` exclusive), or `null` for none. Decodes the whole frame, so it + * finds a solid test colour wherever it really is on screen. + */ +export async function colourPixels( + file: string, + colour: Colour, + tolerance = COLOUR_TOLERANCE, +): Promise<{ + count: number; + box: { left: number; top: number; right: number; bottom: number } | null; + width: number; + height: number; +}> { + const { width, height } = await imageSize(file); + const { stdout } = await runBinary('ffmpeg', [ + '-v', 'error', + '-i', file, + '-frames:v', '1', + '-pix_fmt', 'rgb24', + '-f', 'rawvideo', + '-', + ]); + if (stdout.length !== width * height * 3) { + throw new Error(`colourPixels: ${file}: expected ${width * height * 3} raw byte(s), got ${stdout.length}`); + } + let count = 0; + let left = width; + let top = height; + let right = 0; + let bottom = 0; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const at = (y * width + x) * 3; + if ( + Math.abs(stdout[at]! - colour.r) <= tolerance && + Math.abs(stdout[at + 1]! - colour.g) <= tolerance && + Math.abs(stdout[at + 2]! - colour.b) <= tolerance + ) { + count++; + left = Math.min(left, x); + top = Math.min(top, y); + right = Math.max(right, x + 1); + bottom = Math.max(bottom, y + 1); + } + } + } + return { count, box: count === 0 ? null : { left, top, right, bottom }, width, height }; +} diff --git a/examples/bare/e2e/secure-modal.test.ts b/examples/bare/e2e/secure-modal.test.ts index 178b504e..62304ff3 100644 --- a/examples/bare/e2e/secure-modal.test.ts +++ b/examples/bare/e2e/secure-modal.test.ts @@ -1,14 +1,34 @@ /** - * Phase 6 Modal follow-up: a `` on the activity root and one - * inside a `` must both cover their on-screen controls. Fabric - * measureInWindow is surface-relative; each rectangle is translated by that - * surface's display origin at pull time. + * `` on the app's root and inside a ``: both must be + * masked in the report. Fabric measureInWindow is surface-relative, and each + * rectangle is translated by its own surface's origin at pull time. * - * Scenario `secure-modal` (scenarios/privacy.tsx). Android: WOD_LX1 with - * `E2E_EDGE_TO_EDGE` naming the installed build. iOS: simulator or XS - * (`E2E_IOS_TARGET`), ground truth from the scenario's measureInWindow logs. + * Scenarios (scenarios/privacy.tsx), one block each: + * - `secure-modal`: a transparent Modal (both platforms). + * - `secure-modal-translucent` (Android): also `statusBarTranslucent`. On a + * build with edge-to-edge off this is the case where the dialog's origin + * (0) differs from the activity root's (the status bar), so it is the one + * that tells a per-surface origin from a shared one. + * - `secure-modal-sheet` (iOS): an opaque `pageSheet`, whose content is + * inset inside the window. + * + * Ground truth is independent of what the app measured: each secure view is + * a colour nothing else on screen has, and a screenshot taken by the device + * itself (`adb exec-out screencap`, `xcrun simctl io screenshot`) finds where + * each really is. The report's screenshot must be dark over both, and must + * not contain either colour anywhere. The iPhone has no command-line + * screenshot, so there only the second check runs. + * + * Android: WOD_LX1 with `E2E_EDGE_TO_EDGE` naming the installed build. + * iOS: simulator or XS (`E2E_IOS_TARGET`). */ +import { execFile } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; import { type PulledBundle, airplane, removePulledBundles } from './bundles'; +import { ADB, ANDROID_SERIAL, IOS_SIMULATOR_ID, iosTarget } from './device'; import { ON_IOS, type Run, @@ -24,7 +44,7 @@ import { stopApp, stopDeviceLog, } from './harness'; -import { LUMA_BRIGHT_MIN, LUMA_DARK_MAX } from './media'; +import { LUMA_BRIGHT_MIN, LUMA_DARK_MAX, colourOf, colourPixels } from './media'; import { type DeviceLog, type LogLine, adb, resetScenario } from './scenario'; import { type Media, @@ -40,181 +60,234 @@ import { uiDump, } from './screen'; +const execFileAsync = promisify(execFile); + jest.setTimeout(4 * 60_000); const SHEET_LABEL = 'bugsee-secure-modal-sheet'; +const MAIN_COLOUR = colourOf('#FF00FF'); +const SHEET_COLOUR = colourOf('#00FFFF'); +/** Pixels of a secure colour a report may show before it counts as a leak (antialiased edges). */ +const LEAK_MAX_PIXELS = 64; +/** A secure view the device screenshot shows must be at least this big to count as found. */ +const FOUND_MIN_PIXELS = 1_000; const EDGE_TO_EDGE = process.env.E2E_EDGE_TO_EDGE; const SERVED = /BugseeRN\s*:\s*secure (published|origin) .*served=/; +const ACTIVITY_ORIGIN = /BugseeRN\s*:\s*secure origin .*kind=activity\b/; + +const SCENARIOS = ON_IOS ? ['secure-modal', 'secure-modal-sheet'] : ['secure-modal', 'secure-modal-translucent']; + +/** The device's own screenshot, or `null` where there is no command-line one (the iPhone). */ +async function deviceScreenshot(dir: string): Promise { + const file = join(dir, 'device.png'); + if (!ON_IOS) { + const { stdout } = await execFileAsync(ADB, ['-s', ANDROID_SERIAL, 'exec-out', 'screencap', '-p'], { + encoding: 'buffer', + maxBuffer: 64 * 1024 * 1024, + }); + writeFileSync(file, stdout as unknown as Buffer); + return file; + } + if (iosTarget() !== 'simulator') { + return null; + } + await execFileAsync('xcrun', ['simctl', 'io', IOS_SIMULATOR_ID, 'screenshot', file]); + return file; +} -describeDevice(` in Modal on ${TARGET_NAME}`, () => { - let log: DeviceLog; - let run: Run; - let nonce: string; - let display: { width: number; height: number }; - let mainBounds: Rect; - let sheetBounds: Rect; - let servedLine: LogLine; - let bundle: PulledBundle; - let media: Media; - - const marker = async (what: string, from: number, timeoutMs = 45_000): Promise => - must( - await log.waitFor(new RegExp(`BUGSEE_E2E secure-modal ${escape(what)}.* nonce=${nonce}`), timeoutMs, from), - `the secure-modal scenario's "${what}" marker`, - run.start, - ); - - beforeAll(async () => { - if (!ON_IOS && EDGE_TO_EDGE !== 'true' && EDGE_TO_EDGE !== 'false') { - throw new Error( - `E2E_EDGE_TO_EDGE must state how the installed build was made, "true" or "false"; got ${JSON.stringify(EDGE_TO_EDGE)}`, +/** `box` (image pixels) as a `Rect` in display units, on an image `imageWidth` wide. */ +function toDisplay(box: { left: number; top: number; right: number; bottom: number }, imageWidth: number, displayWidth: number): Rect { + const scale = displayWidth / imageWidth; + return { left: box.left * scale, top: box.top * scale, right: box.right * scale, bottom: box.bottom * scale }; +} + +for (const scenario of SCENARIOS) { + describeDevice(` in ${scenario} on ${TARGET_NAME}`, () => { + let log: DeviceLog; + let run: Run; + let nonce: string; + let display: { width: number; height: number }; + let shotDir: string; + let deviceShot: string | null; + let found: { main: Rect; sheet: Rect } | null = null; + let sheetFromDump: Rect | undefined; + let mainFromJs: Rect | undefined; + let servedLine: LogLine | undefined; + let bundle: PulledBundle; + let media: Media; + + const marker = async (what: string, from: number, timeoutMs = 45_000): Promise => + must( + await log.waitFor(new RegExp(`BUGSEE_E2E ${escape(scenario)} ${escape(what)}.* nonce=${nonce}`), timeoutMs, from), + `the ${scenario} scenario's "${what}" marker`, + run.start, ); - } - log = await startDeviceLog('6.modal', '6.modal'); - if (!ON_IOS) { - await adb('shell', 'setprop', 'log.tag.BugseeRN', 'DEBUG'); - await airplane(true); - display = await displaySize(); - } - await clearBundles(); - run = await startRun('secure-modal'); - nonce = run.scenario.nonce; - report('banner', run.banner.text.trim()); + beforeAll(async () => { + if (!ON_IOS && EDGE_TO_EDGE !== 'true' && EDGE_TO_EDGE !== 'false') { + throw new Error( + `E2E_EDGE_TO_EDGE must state how the installed build was made, "true" or "false"; got ${JSON.stringify(EDGE_TO_EDGE)}`, + ); + } + shotDir = mkdtempSync(join(tmpdir(), 'bugsee-secure-modal-')); + log = await startDeviceLog('6.modal', '6.modal'); + if (!ON_IOS) { + await adb('shell', 'setprop', 'log.tag.BugseeRN', 'DEBUG'); + await airplane(true); + display = await displaySize(); + } - const rect = await marker('rect phase=both', run.launched.index); - await marker('uploaded', rect.index); - const still = await marker('still', rect.index); + await clearBundles(); + run = await startRun(scenario); + nonce = run.scenario.nonce; + report('banner', run.banner.text.trim()); - const main = /main=(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)/.exec(rect.text); - const sheet = /sheet=(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)/.exec(rect.text); - if (main === null || sheet === null) { - throw new Error(`could not parse main/sheet from ${rect.text}`); - } - const ratio = Number(/\bratio=([\d.]+)/.exec(rect.text)?.[1] ?? (ON_IOS ? '1' : 'NaN')); - if (!Number.isFinite(ratio) || ratio <= 0) { - throw new Error(`could not parse PixelRatio from ${rect.text}`); - } + const rect = await marker('rect phase=both', run.launched.index); + await marker('uploaded', rect.index); + const still = await marker('still', rect.index); + if (ON_IOS) { + display = markerScreen(rect); + } + report('JS rectangles', rect.text.replace(/^.*rect phase=both /, '')); - if (ON_IOS) { - // Simulator window points == screen points (Task 6.9 rule). - display = markerScreen(rect); - mainBounds = { - left: Number(main[1]), - top: Number(main[2]), - right: Number(main[1]) + Number(main[3]), - bottom: Number(main[2]) + Number(main[4]), - }; - sheetBounds = { - left: Number(sheet[1]), - top: Number(sheet[2]), - right: Number(sheet[1]) + Number(sheet[3]), - bottom: Number(sheet[2]) + Number(sheet[4]), - }; - report('JS rectangles (points)', { main: mainBounds, sheet: sheetBounds }); - } else { - // A focused Android Dialog's accessibility dump does not include the - // activity-root control behind it. Sheet bounds come from uiautomator; - // main bounds from the twin's measureInWindow, scaled like the registry - // (and shifted by the activity origin when edge-to-edge is off). - const upTo = log.mark(); - servedLine = must(log.all(SERVED, run.start, upTo).pop(), 'a served line before the dump', run.start); - const originLine = log.all(/BugseeRN\s*:\s*secure origin .*surface=0\b/, run.start, upTo).pop() - ?? log.all(/BugseeRN\s*:\s*secure origin /, run.start, upTo).pop(); - const origin = /origin=(-?\d+),(-?\d+)/.exec(originLine?.text ?? ''); - const ox = origin ? Number(origin[1]) : 0; - const oy = origin ? Number(origin[2]) : 0; - mainBounds = { - left: Math.round(Number(main[1]) * ratio) + ox, - top: Math.round(Number(main[2]) * ratio) + oy, - right: Math.round((Number(main[1]) + Number(main[3])) * ratio) + ox, - bottom: Math.round((Number(main[2]) + Number(main[4])) * ratio) + oy, - }; - const { xml } = await uiDump(); - sheetBounds = boundsIn(xml, SHEET_LABEL); - report('bounds', { - mainFromJs: mainBounds, - sheetFromDump: sheetBounds, - origin: { ox, oy }, - ratio, - stillBeforeDump: still.deviceMs, - }); - report('served', servedLine.text.trim()); - } + // While the scenario holds still: the device's own picture of where + // the two secure views are. + deviceShot = await deviceScreenshot(shotDir); + if (deviceShot !== null) { + const main = await colourPixels(deviceShot, MAIN_COLOUR); + const sheet = await colourPixels(deviceShot, SHEET_COLOUR); + report('device screenshot', { main, sheet }); + if (main.box !== null && sheet.box !== null) { + found = { + main: toDisplay(main.box, main.width, display.width), + sheet: toDisplay(sheet.box, sheet.width, display.width), + }; + } + expect(main.count).toBeGreaterThanOrEqual(FOUND_MIN_PIXELS); + expect(sheet.count).toBeGreaterThanOrEqual(FOUND_MIN_PIXELS); + } + + if (!ON_IOS) { + // Before the dump: a focused Dialog's accessibility dump leaves out + // the activity-root control behind it. + const upTo = log.mark(); + servedLine = must(log.all(SERVED, run.start, upTo).pop(), 'a served line before the dump', run.start); + const activity = must( + log.all(ACTIVITY_ORIGIN, run.start, upTo).pop(), + "the activity root's origin line", + run.start, + ); + const origin = /origin=(-?\d+),(-?\d+)/.exec(activity.text); + const ratio = Number(/\bratio=([\d.]+)/.exec(rect.text)?.[1] ?? 'NaN'); + const main = /main=(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)/.exec(rect.text); + if (origin === null || main === null || !Number.isFinite(ratio) || ratio <= 0) { + throw new Error(`could not parse the activity origin, ratio or main rect: ${activity.text} / ${rect.text}`); + } + const [ox, oy] = [Number(origin[1]), Number(origin[2])]; + mainFromJs = { + left: Math.round(Number(main[1]) * ratio) + ox, + top: Math.round(Number(main[2]) * ratio) + oy, + right: Math.round((Number(main[1]) + Number(main[3])) * ratio) + ox, + bottom: Math.round((Number(main[2]) + Number(main[4])) * ratio) + oy, + }; + const { xml } = await uiDump(); + sheetFromDump = boundsIn(xml, SHEET_LABEL); + report('bounds', { mainFromJs, sheetFromDump, activityOrigin: { ox, oy }, ratio, stillBeforeDump: still.deviceMs }); + report('origins', log.all(/BugseeRN\s*:\s*secure origin /, run.start, upTo).map(line => line.text.trim())); + report('served', servedLine.text.trim()); + } - report('display', display); - const bundles = await awaitBundles(1); - bundle = bundleBySummary(bundles, `secure-modal-${nonce}`); - media = await assertMedia(bundle); - report('media', { - screenshots: media.screenshots.length, - codecs: media.screenshotCodecs, - video: media.videoCodec, + report('display', display); + const bundles = await awaitBundles(1); + bundle = bundleBySummary(bundles, `${scenario}-${nonce}`); + media = await assertMedia(bundle); + report('media', { screenshots: media.screenshots.length, codecs: media.screenshotCodecs, video: media.videoCodec }); }); - }); - afterAll(async () => { - try { - await stopApp(); - await clearBundles().catch((error: unknown) => report('cleanup clear failed', String(error))); - } finally { + afterAll(async () => { try { - if (!ON_IOS) { - await airplane(false); - } + await stopApp(); + await clearBundles().catch((error: unknown) => report('cleanup clear failed', String(error))); } finally { - if (!ON_IOS) { - await adb('shell', 'setprop', 'log.tag.BugseeRN', "''").catch(() => {}); + try { + if (!ON_IOS) { + await airplane(false); + } + } finally { + if (!ON_IOS) { + await adb('shell', 'setprop', 'log.tag.BugseeRN', "''").catch(() => {}); + } + rmSync(shotDir, { recursive: true, force: true }); + const { removed, kept } = removePulledBundles(); + report('pulled bundle roots', { removed: removed.length, kept }); + stopDeviceLog(log); + resetScenario(); } - const { removed, kept } = removePulledBundles(); - report('pulled bundle roots', { removed: removed.length, kept }); - stopDeviceLog(log); - resetScenario(); } - } - }); - - it('both the main and modal secure regions are masked', async () => { - const mainInner = await screenshotLumas(media.screenshots, mainBounds, display.width, 0.6); - const sheetInner = await screenshotLumas(media.screenshots, sheetBounds, display.width, 0.6); - const control: Rect = moved(mainBounds, (mainBounds.bottom - mainBounds.top) + 40); - const controlLumas = await screenshotLumas(media.screenshots, control, display.width); - report('lumas', { mainInner, sheetInner, control, controlLumas }); + }); - for (const luma of mainInner) { - expect(luma).toBeLessThanOrEqual(LUMA_DARK_MAX); - } - for (const luma of sheetInner) { - expect(luma).toBeLessThanOrEqual(LUMA_DARK_MAX); - } - for (const luma of controlLumas) { - expect(luma).toBeGreaterThanOrEqual(LUMA_BRIGHT_MIN); - } + it('the report shows neither secure colour anywhere', async () => { + const leaks = []; + for (const file of media.screenshots) { + leaks.push({ + main: (await colourPixels(file, MAIN_COLOUR)).count, + sheet: (await colourPixels(file, SHEET_COLOUR)).count, + }); + } + report('secure colour pixels in the report', leaks); + for (const leak of leaks) { + expect(leak.main).toBeLessThanOrEqual(LEAK_MAX_PIXELS); + expect(leak.sheet).toBeLessThanOrEqual(LEAK_MAX_PIXELS); + } + }); - if (ON_IOS) { - return; - } + it('the report is dark where the device shows each secure view', async () => { + if (deviceShot === null) { + report('device screenshot', 'none on this target: only the colour check above applies'); + return; + } + if (found === null) { + throw new Error('the device screenshot does not show both secure colours'); + } + const where = found; + const mainInner = await screenshotLumas(media.screenshots, where.main, display.width, 0.6); + const sheetInner = await screenshotLumas(media.screenshots, where.sheet, display.width, 0.6); + const control: Rect = moved(where.main, (where.main.bottom - where.main.top) + 40); + const controlLumas = await screenshotLumas(media.screenshots, control, display.width); + report('lumas', { found: where, mainInner, sheetInner, control, controlLumas }); - const served = servedOf(servedLine); - expect(served.count).toBeGreaterThanOrEqual(2); - report('served vs on screen', { - edgeToEdge: EDGE_TO_EDGE, - served: served.rects, - main: mainBounds, - sheet: sheetBounds, + for (const luma of [...mainInner, ...sheetInner]) { + expect(luma).toBeLessThanOrEqual(LUMA_DARK_MAX); + } + for (const luma of controlLumas) { + expect(luma).toBeGreaterThanOrEqual(LUMA_BRIGHT_MIN); + } }); - const covers = (rect: { left: number; top: number; right: number; bottom: number }, onScreen: Rect): boolean => - rect.left <= onScreen.left && - rect.top <= onScreen.top && - rect.right >= onScreen.right && - rect.bottom >= onScreen.bottom && - onScreen.left - rect.left <= 2 && - onScreen.top - rect.top <= 2 && - rect.right - onScreen.right <= 2 && - rect.bottom - onScreen.bottom <= 2; - - expect(served.rects.some(r => covers(r, mainBounds))).toBe(true); - expect(served.rects.some(r => covers(r, sheetBounds))).toBe(true); + if (!ON_IOS) { + it('the served set covers the main rectangle and the sheet', () => { + const served = servedOf(servedLine as LogLine); + report('served vs on screen', { + edgeToEdge: EDGE_TO_EDGE, + served: served.rects, + main: mainFromJs, + sheet: sheetFromDump, + found, + }); + const covers = (rect: Rect, onScreen: Rect): boolean => + rect.left <= onScreen.left && + rect.top <= onScreen.top && + rect.right >= onScreen.right && + rect.bottom >= onScreen.bottom && + onScreen.left - rect.left <= 2 && + onScreen.top - rect.top <= 2 && + rect.right - onScreen.right <= 2 && + rect.bottom - onScreen.bottom <= 2; + + expect(served.count).toBeGreaterThanOrEqual(2); + expect(served.rects.some(r => covers(r, mainFromJs as Rect))).toBe(true); + expect(served.rects.some(r => covers(r, sheetFromDump as Rect))).toBe(true); + }); + } }); -}); +} diff --git a/examples/bare/scenarios/privacy.tsx b/examples/bare/scenarios/privacy.tsx index 264b5b88..ad04df89 100644 --- a/examples/bare/scenarios/privacy.tsx +++ b/examples/bare/scenarios/privacy.tsx @@ -54,6 +54,8 @@ export const PRIVACY_SCENARIOS = [ 'blackout-prelaunch', 'secure-component', 'secure-modal', + 'secure-modal-translucent', + 'secure-modal-sheet', 'view-tree', ] as const; @@ -73,6 +75,25 @@ export const SECURE_COMPONENT_LABEL = 'bugsee-secure-component'; export const SECURE_WITNESS_LABEL = 'bugsee-secure-witness'; export const SECURE_MODAL_MAIN_LABEL = 'bugsee-secure-modal-main'; export const SECURE_MODAL_SHEET_LABEL = 'bugsee-secure-modal-sheet'; +/** + * The two secure views' colours: nothing else on screen is either, so a + * device screenshot finds where each one really is, and a recording that + * shows either colour has leaked it. + */ +export const SECURE_MODAL_MAIN_COLOUR = '#FF00FF'; +export const SECURE_MODAL_SHEET_COLOUR = '#00FFFF'; + +/** + * The Modal each `secure-modal*` scenario shows: + * - `secure-modal`: `transparent` (overFullScreen on iOS). + * - `secure-modal-translucent`: also `statusBarTranslucent`. With Android + * edge-to-edge off, the dialog's content then starts under the status bar + * while the activity root starts below it, so the two surfaces' origins + * differ. + * - `secure-modal-sheet`: an opaque `pageSheet` (iOS), whose content is + * inset inside the window. + */ +export type SecureModalVariant = 'secure-modal' | 'secure-modal-translucent' | 'secure-modal-sheet'; export const VH_OPEN_LABEL = 'vh-open-probe'; function mark(message: string): void { @@ -318,18 +339,18 @@ const MODAL_MAIN_RECT = { position: 'absolute', top: 120, left: 40, width: 160, const MODAL_SHEET_RECT = { position: 'absolute', top: 80, left: 80, width: 200, height: 100 } as const; /** - * secure-modal: a white `` on the activity root and another + * secure-modal*: a coloured `` on the activity root and another * inside a `` (Android Dialog / iOS presented VC). Both must land in * display space for their own surface โ€” the sheet must not be translated by - * the activity root's origin alone. + * the activity root's origin alone. Markers are tagged with the scenario. */ -function SecureModalProbe({ nonce, setMoving }: ProbeProps) { +function SecureModalProbe({ nonce, setMoving, variant }: ProbeProps & { variant: SecureModalVariant }) { const mainTwin = useRef(null); const sheetTwin = useRef(null); useLayoutEffect(() => { - mark(`secure-modal mounted t=${Date.now()} nonce=${nonce}`); - }, [nonce]); + mark(`${variant} mounted t=${Date.now()} nonce=${nonce}`); + }, [nonce, variant]); useEffect(() => { (async () => { @@ -337,36 +358,42 @@ function SecureModalProbe({ nonce, setMoving }: ProbeProps) { const main = await measure(mainTwin.current); const sheet = await measure(sheetTwin.current); mark( - `secure-modal rect phase=both main=${main.x},${main.y},${main.w},${main.h} ` + + `${variant} rect phase=both main=${main.x},${main.y},${main.w},${main.h} ` + `sheet=${sheet.x},${sheet.y},${sheet.w},${sheet.h} ${screenLine()} nonce=${nonce}`, ); await sleep(1500); - Bugsee.upload(`secure-modal-${nonce}`, ''); - mark(`secure-modal uploaded t=${Date.now()} nonce=${nonce}`); + Bugsee.upload(`${variant}-${nonce}`, ''); + mark(`${variant} uploaded t=${Date.now()} nonce=${nonce}`); await sleep(SETTLE_MS); setMoving(false); - mark(`secure-modal still t=${Date.now()} nonce=${nonce}`); + mark(`${variant} still t=${Date.now()} nonce=${nonce}`); await sleep(HOLD_MS); setMoving(true); })().catch((error: unknown) => { - mark(`secure-modal threw ${String(error)} nonce=${nonce}`); + mark(`${variant} threw ${String(error)} nonce=${nonce}`); }); - }, [nonce, setMoving]); + }, [nonce, setMoving, variant]); return ( <> - + @@ -453,9 +480,12 @@ export function PrivacyStage({ {launched && scenario === 'secure-component' && ( )} - {launched && scenario === 'secure-modal' && ( - - )} + {launched && + (scenario === 'secure-modal' || + scenario === 'secure-modal-translucent' || + scenario === 'secure-modal-sheet') && ( + + )} {launched && scenario === 'view-tree' && ( )} From 020b682e7ca88e4c9a64c2b3180428edc3bad4f7 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Mon, 5 Oct 2026 11:48:53 +0500 Subject: [PATCH 07/19] test(e2e): an opaque pageSheet hides the main secure view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On an iPhone the pageSheet covers the presenting view, so the device screenshot shows only the Modal's colour. The sheet case now requires the main colour to be absent from the device screenshot and checks the report over the sheet alone; the bright control sits inside the Modal's white backdrop below its secure view in every case. Simulator: secure-modal 2/2 and secure-modal-sheet 2/2, sheet found at 104,342-304,442 pt (62 pt below its measureInWindow), sheetInner 0, no secure colour in the report. With every Modal placed at the window origin (the pre-I1 rule) the sheet case fails: 17760 cyan pixels, sheetInner 114.9. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --- examples/bare/e2e/secure-modal.test.ts | 29 +++++++++++++++++--------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/examples/bare/e2e/secure-modal.test.ts b/examples/bare/e2e/secure-modal.test.ts index 62304ff3..f6450f49 100644 --- a/examples/bare/e2e/secure-modal.test.ts +++ b/examples/bare/e2e/secure-modal.test.ts @@ -15,9 +15,11 @@ * Ground truth is independent of what the app measured: each secure view is * a colour nothing else on screen has, and a screenshot taken by the device * itself (`adb exec-out screencap`, `xcrun simctl io screenshot`) finds where - * each really is. The report's screenshot must be dark over both, and must - * not contain either colour anywhere. The iPhone has no command-line - * screenshot, so there only the second check runs. + * each really is. The report's screenshot must be dark over each one the + * device shows, and must not contain either colour anywhere. An opaque + * `pageSheet` covers the main view entirely on an iPhone, so there only the + * sheet's colour is on screen. The iPhone has no command-line screenshot, so + * there only the colour check runs. * * Android: WOD_LX1 with `E2E_EDGE_TO_EDGE` naming the installed build. * iOS: simulator or XS (`E2E_IOS_TARGET`). @@ -109,7 +111,8 @@ for (const scenario of SCENARIOS) { let display: { width: number; height: number }; let shotDir: string; let deviceShot: string | null; - let found: { main: Rect; sheet: Rect } | null = null; + /** Where the device shows each secure view (display units); `main` is absent behind an opaque sheet. */ + let found: { main: Rect | null; sheet: Rect } | null = null; let sheetFromDump: Rect | undefined; let mainFromJs: Rect | undefined; let servedLine: LogLine | undefined; @@ -157,14 +160,19 @@ for (const scenario of SCENARIOS) { const main = await colourPixels(deviceShot, MAIN_COLOUR); const sheet = await colourPixels(deviceShot, SHEET_COLOUR); report('device screenshot', { main, sheet }); - if (main.box !== null && sheet.box !== null) { + if (sheet.box !== null) { found = { - main: toDisplay(main.box, main.width, display.width), + main: main.box === null ? null : toDisplay(main.box, main.width, display.width), sheet: toDisplay(sheet.box, sheet.width, display.width), }; } - expect(main.count).toBeGreaterThanOrEqual(FOUND_MIN_PIXELS); expect(sheet.count).toBeGreaterThanOrEqual(FOUND_MIN_PIXELS); + if (scenario === 'secure-modal-sheet') { + // The opaque sheet hides the main view: the device must not show it. + expect(main.count).toBe(0); + } else { + expect(main.count).toBeGreaterThanOrEqual(FOUND_MIN_PIXELS); + } } if (!ON_IOS) { @@ -247,12 +255,13 @@ for (const scenario of SCENARIOS) { return; } if (found === null) { - throw new Error('the device screenshot does not show both secure colours'); + throw new Error("the device screenshot does not show the Modal's secure colour"); } const where = found; - const mainInner = await screenshotLumas(media.screenshots, where.main, display.width, 0.6); + const mainInner = where.main === null ? [] : await screenshotLumas(media.screenshots, where.main, display.width, 0.6); const sheetInner = await screenshotLumas(media.screenshots, where.sheet, display.width, 0.6); - const control: Rect = moved(where.main, (where.main.bottom - where.main.top) + 40); + // Inside the Modal's white backdrop, below its secure view. + const control: Rect = moved(where.sheet, (where.sheet.bottom - where.sheet.top) + 40); const controlLumas = await screenshotLumas(media.screenshots, control, display.width); report('lumas', { found: where, mainInner, sheetInner, control, controlLumas }); From 5877a035b8b70a99e0a63308d045e0fbd094f88a Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Mon, 5 Oct 2026 12:37:11 +0500 Subject: [PATCH 08/19] test(secure): expect the error's name in BugseeSecure's warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 45 (0eb3cf0) made warn with the error's name rather than the error itself. The surface case added on this branch now expects the same shape. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --- .../react-native/src/secure/__tests__/BugseeSecure.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native/src/secure/__tests__/BugseeSecure.test.tsx b/packages/react-native/src/secure/__tests__/BugseeSecure.test.tsx index 3e76b102..140b174a 100644 --- a/packages/react-native/src/secure/__tests__/BugseeSecure.test.tsx +++ b/packages/react-native/src/secure/__tests__/BugseeSecure.test.tsx @@ -409,6 +409,6 @@ describe(' surfaces', () => { expect(native.setSecureRectangles).not.toHaveBeenCalled(); expect(native.setSecureRectanglesOnSurface).not.toHaveBeenCalled(); expect(warn).toHaveBeenCalledTimes(1); - expect(warn).toHaveBeenCalledWith('[Bugsee] BugseeSecure could not measure', failure); + expect(warn).toHaveBeenCalledWith('[Bugsee] BugseeSecure could not measure', failure.name); }); }); From 9ec0e5ece72a54314f474db9704708aa71a36b27 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Mon, 5 Oct 2026 12:46:42 +0500 Subject: [PATCH 09/19] fix(privacy): bound and clamp what the secure store serves, and fail closed on every lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-review fix round 2 (N1-N4). N1: a lane with an unknown origin serves the display's real bounds, not a 2^20 square. Android records Display.getRealSize from the root's display on every origin read and from DisplayManager on the first publish; iOS records UIScreen.mainScreen.bounds on every pull. Android also clamps every served rectangle to the display and drops one left with no area: the SDK's native video mask (jni/video.c hideRects) clamps rows to the frame but not a rectangle's right edge to a row, so an oversized one blacked out the start of the next row on every row. Before the size is known the fallback is a 16384 square. N2 (Android): a dialog root the tracker forgets (seen detached on a refresh, or dropped by detach() on host destroy) while its surface holds rectangles has its origin reset to unknown, so it fails closed, and goes back to pending, so a later refresh re-watches it. No origin is recorded from a root that is not attached to its window. N3 (Android): only a non-empty publish watches a surface, and a pending surface whose rectangles JS has cleared is dropped with its empty lane instead of being retried. N4 (ruling): the main lane fails closed too. Until its origin has been read once, its rectangles are served as one display-bounds rectangle. iOS: a note at BGSRNModalSurfaceOrigin for #30's rebase. Tests: JVM root and example-Gradle 332 tests, 0 failures; BugseeRNSupport 261, 0 failures. Mutations, each reverted: main lane not failing closed (13 JVM failures, 4 XCTest), no clamp (everyServedRectangleIsClampedToTheDisplay), no forgetOrigin (2), recording an unattached root's origin (2), no pending prune (2), iOS screen size ignored (4). ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --- .../com/bugsee/reactnative/BugseeModule.java | 42 +++- .../reactnative/ReactRootOriginTracker.java | 66 +++++- .../reactnative/SecureRectangleStore.java | 188 +++++++++++++---- .../BugseeReactNativeWrapperTest.java | 3 + .../ReactRootOriginTrackerTest.java | 144 ++++++++++++- .../reactnative/SecureRectangleStoreTest.java | 190 ++++++++++++++---- packages/react-native/ios/BugseeModule.mm | 6 + .../BugseeRNSupport/BGSRNSecureRectangles.m | 82 +++++--- .../include/BGSRNSecureRectangles.h | 15 +- .../BGSRNSecureRectanglesTests.m | 60 +++++- 10 files changed, 656 insertions(+), 140 deletions(-) diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java index 0c435458..a9edd3cd 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java @@ -3,8 +3,12 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import android.content.Context; +import android.graphics.Point; +import android.hardware.display.DisplayManager; import android.util.Base64; import android.util.Log; +import android.view.Display; import com.bugsee.library.Bugsee; import com.bugsee.library.contracts.exchange.Breadcrumb; @@ -271,17 +275,24 @@ private void publishSecure(final double display, final int surface, final Readab for (int i = 0; i < flat.length; i++) { flat[i] = (int) Math.round(coordinates.getDouble(i)); } - // Stored as measured (relative to that surface's viewport - // offset); the store serves them moved by that surface's display - // origin, which the tracker keeps current. Re-read now too, in - // case the window moved without a layout pass. + // The display's real size first: until an origin is read the + // store serves the whole display, and it must know how big that + // is. Display reads are safe off the UI thread. + recordDisplayBounds((int) display); + // Stored as measured (relative to that surface's own origin); + // the store serves them moved by that surface's display origin, + // which the tracker keeps current. Re-read now too, in case the + // window moved without a layout pass. if (!SecureRectangleStore.shared().publishOrLog((int) display, surface, flat)) { return; } originTracker.refreshSoon(); - // A 's first publish: find and watch its dialog root. - // Until then its rectangles fail closed in the store. - originTracker.watchSurface(surface); + // A 's first rectangles: find and watch its dialog root. + // Until then they fail closed in the store. An empty publish + // (the Modal clearing on unmount) watches nothing. + if (flat.length > 0) { + originTracker.watchSurface(surface); + } if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "secure published display=" + (int) display + " surface=" + surface @@ -294,6 +305,23 @@ private void publishSecure(final double display, final int surface, final Readab } } + /** Records {@code display}'s real size in the store, once. */ + private void recordDisplayBounds(final int display) { + final SecureRectangleStore store = SecureRectangleStore.shared(); + if (store.hasDisplayBounds(display)) { + return; + } + final DisplayManager displays = + (DisplayManager) getReactApplicationContext().getSystemService(Context.DISPLAY_SERVICE); + final Display found = displays == null ? null : displays.getDisplay(display); + if (found == null) { + return; + } + final Point size = new Point(); + found.getRealSize(size); + store.setDisplayBounds(display, size.x, size.y); + } + /** * The display origin {@code [x, y]} of the {@code } whose host has * React tag {@code surface}, for the {@code vh} walk, which asks once per diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/ReactRootOriginTracker.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/ReactRootOriginTracker.java index 25b54bb6..fb97098c 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/ReactRootOriginTracker.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/ReactRootOriginTracker.java @@ -319,12 +319,22 @@ private RootHandle resolveQuietly(final int surfaceKey) { } } + /** + * Retries every pending surface. One whose rectangles JS has cleared + * meanwhile (its Modal unmounted before its root was found) is not + * retried: it is dropped with its empty lane. + */ @UiThread private void resolvePending() { if (pendingSurfaces.isEmpty()) { return; } for (final Integer surfaceKey : new ArrayList<>(pendingSurfaces)) { + if (!store.hasRectangles(surfaceKey)) { + pendingSurfaces.remove(surfaceKey); + store.dropSurfaceIfEmpty(surfaceKey); + continue; + } watchSurfaceNow(surfaceKey); } } @@ -336,15 +346,32 @@ private void refreshWatched() { final WatchedRoot entry = it.next(); if (!entry.handle.isAttachedToWindow()) { it.remove(); - entry.token.release(); - surfaceOrigins.remove(entry.handle.surfaceKey()); - store.dropSurfaceIfEmpty(entry.handle.surfaceKey()); + forget(entry); continue; } publishOrigin(entry.handle, false); } } + /** + * Stops watching a dialog root. If its surface still holds rectangles, + * its origin no longer holds: the store serves the surface as the whole + * display again and the key goes back to pending, so a later refresh + * watches whatever root then hosts it. Otherwise its empty lane goes. + */ + @UiThread + private void forget(@NonNull final WatchedRoot entry) { + entry.token.release(); + final int surfaceKey = entry.handle.surfaceKey(); + surfaceOrigins.remove(surfaceKey); + if (store.hasRectangles(surfaceKey)) { + store.forgetOrigin(surfaceKey); + pendingSurfaces.add(surfaceKey); + } else { + store.dropSurfaceIfEmpty(surfaceKey); + } + } + /** * Where {@code measureInWindow}'s (0, 0) sits on the display for the * root in {@code snapshot}: {@code locationOnScreen - viewportOffset} for @@ -369,10 +396,19 @@ private void publishOrigin(@NonNull final RootHandle handle, final boolean prima // not a cosmetic one, so a gone-mid-refresh view aborts the whole // read instead: nothing new is published and the previous origin // stands. + // A root that is not attached has no place on screen yet: its + // location reads (0, 0). Nothing is recorded; the surface keeps + // failing closed until its first layout after attaching. + if (!handle.isAttachedToWindow()) { + return; + } final OriginSnapshot snapshot = handle.resolveOrigin(); if (snapshot == null) { return; } + if (snapshot.displaySize != null) { + store.setDisplayBounds(snapshot.displayId, snapshot.displaySize.x, snapshot.displaySize.y); + } final int[] origin = surfaceOrigin(snapshot); // The activity root is the main surface whatever its own view id: // JS publishes everything outside a there. @@ -416,11 +452,13 @@ int[] currentOrigin() { @UiThread void detach() { detachPrimary(); - for (final WatchedRoot entry : watched) { - entry.token.release(); + // Surfaces that still hold rectangles stay pending (failing closed) + // for a root a recreated activity brings back; refresh re-watches + // them. After dispose() nothing refreshes again. + for (final WatchedRoot entry : new ArrayList<>(watched)) { + forget(entry); } watched.clear(); - pendingSurfaces.clear(); surfaceOrigins.clear(); } @@ -510,6 +548,9 @@ static final class OriginSnapshot { final Point viewport; final int displayId; final boolean reactRoot; + /** The display's real size in pixels ({@code Display.getRealSize}), or {@code null}. */ + @Nullable + final Point displaySize; OriginSnapshot(final int[] onScreen, final Point viewport, final int displayId) { this(onScreen, viewport, displayId, true); @@ -517,10 +558,16 @@ static final class OriginSnapshot { OriginSnapshot(final int[] onScreen, final Point viewport, final int displayId, final boolean reactRoot) { + this(onScreen, viewport, displayId, reactRoot, null); + } + + OriginSnapshot(final int[] onScreen, final Point viewport, final int displayId, + final boolean reactRoot, @Nullable final Point displaySize) { this.onScreen = onScreen; this.viewport = viewport; this.displayId = displayId; this.reactRoot = reactRoot; + this.displaySize = displaySize; } } @@ -677,7 +724,12 @@ public OriginSnapshot resolveOrigin() { final Point viewport = RootViewUtil.getViewportOffset(v); final Display display = v.getDisplay(); final int displayId = display == null ? Display.DEFAULT_DISPLAY : display.getDisplayId(); - return new OriginSnapshot(onScreen, viewport, displayId, reactRoot); + Point size = null; + if (display != null) { + size = new Point(); + display.getRealSize(size); + } + return new OriginSnapshot(onScreen, viewport, displayId, reactRoot, size); } @Override diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/SecureRectangleStore.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/SecureRectangleStore.java index 06a4b150..2fdacbad 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/SecureRectangleStore.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/SecureRectangleStore.java @@ -43,12 +43,18 @@ * ReactRootOriginTracker#surfaceOrigin}). A single origin for the whole * display cannot serve both. * - *

Fails closed. A surface other than {@link #MAIN_SURFACE} whose origin - * has not been read yet serves its rectangles as one {@link #FULL_DISPLAY} - * rectangle: an unknown origin must not place a secure region anywhere it - * could miss, so until the tracker reads it the whole display is redacted. - * The main surface keeps (0, 0) until its origin is read, as before - * surfaces existed: the activity root is tracked from module start. + *

Fails closed. A surface whose origin has not been read yet, or whose + * root was forgotten ({@link #forgetOrigin}), serves its rectangles as one + * rectangle covering the whole display: an unknown origin must not place a + * secure region anywhere it could miss. That includes the main surface, so + * the very first publish masks the whole display until the tracker's first + * read, a fraction of a second. + * + *

Every served rectangle is clamped to the display's real size once it + * is known ({@link #setDisplayBounds}). The SDK's native video mask clamps a + * rectangle's rows to the frame but not its right edge to a row, so a + * rectangle wider than the frame would black out the start of the next row + * on every row: it must never be handed one. * *

Threading

* @@ -86,11 +92,12 @@ final class SecureRectangleStore { static final int MAIN_SURFACE = 0; /** - * What a surface whose origin is unknown serves in place of its - * rectangles: larger than any display, small enough that the SDK's own - * scaling cannot overflow an {@code int}. + * The bounds a display serves a fail-closed lane as when its real size + * has not been recorded ({@link #setDisplayBounds}): bigger than any + * phone or tablet panel, and small enough that a renderer that does not + * clip a rectangle's right edge per row still stays cheap. */ - static final int[] FULL_DISPLAY = { 0, 0, 1 << 20, 1 << 20 }; + static final int FALLBACK_DISPLAY_SIZE = 16384; /** One surface's raw measurements and the origin that places them. */ private static final class Lane { @@ -98,37 +105,29 @@ private static final class Lane { final int[] raw; final int originX; final int originY; - /** Whether {@link #originX}/{@link #originY} were ever read. */ + /** Whether {@link #originX}/{@link #originY} were read (and still hold). */ final boolean originKnown; - /** Whether an unknown origin redacts the whole display (every non-main surface). */ - final boolean failClosed; - /** {@link #raw} moved by this surface's origin: what the SDK is served. */ - final int[] coordinates; - Lane(@NonNull final int[] raw, final int originX, final int originY, - final boolean originKnown, final boolean failClosed) { + Lane(@NonNull final int[] raw, final int originX, final int originY, final boolean originKnown) { this.raw = raw; this.originX = originX; this.originY = originY; this.originKnown = originKnown; - this.failClosed = failClosed; - if (raw.length == 0) { - this.coordinates = NO_COORDINATES; - } else if (!originKnown && failClosed) { - this.coordinates = FULL_DISPLAY.clone(); - } else { - this.coordinates = translate(raw, originX, originY); - } } @NonNull Lane withRaw(@NonNull final int[] newRaw) { - return new Lane(newRaw, originX, originY, originKnown, failClosed); + return new Lane(newRaw, originX, originY, originKnown); } @NonNull Lane withOrigin(final int newOriginX, final int newOriginY) { - return new Lane(raw, newOriginX, newOriginY, true, failClosed); + return new Lane(raw, newOriginX, newOriginY, true); + } + + @NonNull + Lane withOriginUnknown() { + return new Lane(raw, originX, originY, false); } } @@ -137,42 +136,95 @@ private static final class Snapshot { final int version; /** Surface key โ†’ lane. TreeMap so the served order is stable. */ final TreeMap lanes; - /** Every lane's translated coordinates, concatenated in surface-key order. */ + /** The display's real size {@code {width, height}} in pixels, or {@code null}. */ + @Nullable + final int[] bounds; + /** What the SDK is served: every lane in surface-key order, clamped to {@link #bounds}. */ final int[] coordinates; - Snapshot(final int version, @NonNull final TreeMap lanes) { + Snapshot(final int version, @NonNull final TreeMap lanes, @Nullable final int[] bounds) { this.version = version; - // Defensive copy: callers must not retain a mutable reference. + // Defensive copies: callers must not retain a mutable reference. this.lanes = new TreeMap<>(lanes); - this.coordinates = merge(this.lanes); + this.bounds = bounds == null ? null : bounds.clone(); + this.coordinates = serve(this.lanes, this.bounds); } @NonNull Snapshot withVersion(final int newVersion) { - return new Snapshot(newVersion, lanes); + return new Snapshot(newVersion, lanes, bounds); + } + + @NonNull + Snapshot withLanes(@NonNull final TreeMap nextLanes) { + return new Snapshot(0, nextLanes, bounds); + } + + @NonNull + Snapshot withBounds(@NonNull final int[] nextBounds) { + return new Snapshot(0, lanes, nextBounds); } + /** + * A lane whose origin is unknown serves the whole display; any other + * serves its rectangles moved by its origin. Every rectangle is then + * clamped to the display, and one left with no area is dropped: a + * renderer is never handed a rectangle that runs off the panel. + */ @NonNull - private static int[] merge(@NonNull final TreeMap lanes) { + private static int[] serve(@NonNull final TreeMap lanes, @Nullable final int[] bounds) { + final int width = bounds == null ? FALLBACK_DISPLAY_SIZE : bounds[0]; + final int height = bounds == null ? FALLBACK_DISPLAY_SIZE : bounds[1]; int total = 0; for (final Lane lane : lanes.values()) { - total += lane.coordinates.length; + total += lane.raw.length == 0 ? 0 : lane.originKnown ? lane.raw.length : COORDINATES_PER_RECTANGLE; } if (total == 0) { return NO_COORDINATES; } - final int[] merged = new int[total]; + final int[] served = new int[total]; int at = 0; for (final Lane lane : lanes.values()) { - System.arraycopy(lane.coordinates, 0, merged, at, lane.coordinates.length); - at += lane.coordinates.length; + if (lane.raw.length == 0) { + continue; + } + if (!lane.originKnown) { + served[at++] = 0; + served[at++] = 0; + served[at++] = width; + served[at++] = height; + continue; + } + final int[] moved = translate(lane.raw, lane.originX, lane.originY); + for (int i = 0; i < moved.length; i += COORDINATES_PER_RECTANGLE) { + if (bounds == null) { + System.arraycopy(moved, i, served, at, COORDINATES_PER_RECTANGLE); + at += COORDINATES_PER_RECTANGLE; + continue; + } + final int left = clamp(moved[i], width); + final int top = clamp(moved[i + 1], height); + final int right = clamp(moved[i + 2], width); + final int bottom = clamp(moved[i + 3], height); + if (right <= left || bottom <= top) { + continue; + } + served[at++] = left; + served[at++] = top; + served[at++] = right; + served[at++] = bottom; + } } - return merged; + return at == served.length ? served : Arrays.copyOf(served, at); + } + + private static int clamp(final int value, final int max) { + return Math.max(0, Math.min(value, max)); } } /** What a display serves before anything is secured or located. */ - private static final Snapshot EMPTY = new Snapshot(INITIAL_VERSION, new TreeMap<>()); + private static final Snapshot EMPTY = new Snapshot(INITIAL_VERSION, new TreeMap<>(), null); /** * The process-wide set of secured regions. @@ -282,6 +334,56 @@ void setOrigin(final int display, final int surface, final int originX, final in lane -> lane.withOrigin(originX, originY))); } + /** + * Records {@code display}'s real size in pixels: what a fail-closed lane + * serves, and what every rectangle is clamped to. + */ + void setDisplayBounds(final int display, final int width, final int height) { + if (width <= 0 || height <= 0) { + return; + } + update(display, previous -> previous.bounds != null + && previous.bounds[0] == width && previous.bounds[1] == height + ? previous + : previous.withBounds(new int[] { width, height })); + } + + /** Whether {@code display}'s real size has been recorded. */ + boolean hasDisplayBounds(final int display) { + final Snapshot current = byDisplay.get(display); + return current != null && current.bounds != null; + } + + /** + * {@code surface}'s root is gone: its origin no longer holds, so every + * display serves its rectangles as the whole display again until a new + * origin is read. An empty lane is unaffected. + */ + void forgetOrigin(final int surface) { + for (final Integer display : byDisplay.keySet()) { + update(display, previous -> { + final Lane lane = previous.lanes.get(surface); + if (lane == null || !lane.originKnown) { + return previous; + } + final TreeMap nextLanes = new TreeMap<>(previous.lanes); + nextLanes.put(surface, lane.withOriginUnknown()); + return previous.withLanes(nextLanes); + }); + } + } + + /** Whether {@code surface} holds rectangles on any display. */ + boolean hasRectangles(final int surface) { + for (final Snapshot snapshot : byDisplay.values()) { + final Lane lane = snapshot.lanes.get(surface); + if (lane != null && lane.raw.length > 0) { + return true; + } + } + return false; + } + /** * Forgets {@code surface} on every display where it holds no rectangles: * its root is gone, and a later root may reuse the key. A surface that @@ -300,7 +402,7 @@ void dropSurfaceIfEmpty(final int surface) { } final TreeMap nextLanes = new TreeMap<>(previous.lanes); nextLanes.remove(surface); - return new Snapshot(0, nextLanes); + return previous.withLanes(nextLanes); }); } } @@ -342,11 +444,9 @@ private static Snapshot withLane(@NonNull final Snapshot previous, final int sur @NonNull final LaneChange change) { final TreeMap nextLanes = new TreeMap<>(previous.lanes); final Lane prior = nextLanes.get(surface); - final Lane base = prior == null - ? new Lane(NO_COORDINATES, 0, 0, false, surface != MAIN_SURFACE) - : prior; + final Lane base = prior == null ? new Lane(NO_COORDINATES, 0, 0, false) : prior; nextLanes.put(surface, change.apply(base)); - return new Snapshot(0, nextLanes); + return previous.withLanes(nextLanes); } /** diff --git a/packages/react-native/android/src/test/java/com/bugsee/reactnative/BugseeReactNativeWrapperTest.java b/packages/react-native/android/src/test/java/com/bugsee/reactnative/BugseeReactNativeWrapperTest.java index 9629f841..58edecc7 100644 --- a/packages/react-native/android/src/test/java/com/bugsee/reactnative/BugseeReactNativeWrapperTest.java +++ b/packages/react-native/android/src/test/java/com/bugsee/reactnative/BugseeReactNativeWrapperTest.java @@ -90,6 +90,9 @@ public void answersAnUnknownDataTypeToo() { */ @Test public void securedRegionsSurviveTheWrapperBeingReplaced() { + // The main surface's origin read, as the tracker does at start; + // before that read the store serves the whole display (fail closed). + SecureRectangleStore.shared().setOrigin(0, 0, 0); SecureRectangleStore.shared().set(0, new int[] { 1, 2, 3, 4 }); final int[] packed = wrapper(context()).getSecureRectangles(0); diff --git a/packages/react-native/android/src/test/java/com/bugsee/reactnative/ReactRootOriginTrackerTest.java b/packages/react-native/android/src/test/java/com/bugsee/reactnative/ReactRootOriginTrackerTest.java index f7145c4b..bead2d06 100644 --- a/packages/react-native/android/src/test/java/com/bugsee/reactnative/ReactRootOriginTrackerTest.java +++ b/packages/react-native/android/src/test/java/com/bugsee/reactnative/ReactRootOriginTrackerTest.java @@ -60,6 +60,8 @@ private static final class FakeRoot implements RootHandle { boolean attached = true; int[] location = { 0, 0 }; Point viewport = new Point(); + /** The display's real size this root reports, or null. */ + Point displaySize; int displayId; int surfaceKey = SecureRectangleStore.MAIN_SURFACE; /** A ReactRoot (the activity root) rather than a dialog root. */ @@ -84,7 +86,8 @@ public OriginSnapshot resolveOrigin() { if (viewGone) { return null; } - return new OriginSnapshot(new int[] { location[0], location[1] }, viewport, displayId, reactRoot); + return new OriginSnapshot(new int[] { location[0], location[1] }, viewport, displayId, reactRoot, + displaySize); } @Override @@ -493,7 +496,7 @@ public void aSurfaceWhoseDialogRootIsNotFoundYetFailsClosedUntilARefreshFindsIt( store.set(0, 56, new int[] { 208, 560, 608, 760 }); tracker.watchSurfaceNow(56); - assertArrayEquals(SecureRectangleStore.FULL_DISPLAY, + assertArrayEquals(WHOLE_FALLBACK, java.util.Arrays.copyOfRange(store.snapshot(0), 2, 6)); assertNull(tracker.cachedSurfaceOrigin(56)); @@ -548,7 +551,7 @@ public void aThrowingResolverLeavesTheSurfacePending() { tracker.refresh(); assertEquals(2, calls[0]); - assertArrayEquals(SecureRectangleStore.FULL_DISPLAY, + assertArrayEquals(WHOLE_FALLBACK, java.util.Arrays.copyOfRange(store.snapshot(0), 2, 6)); } @@ -588,7 +591,7 @@ public void aDetachedDialogRootDropsItsEmptyLaneAndItsOrigin() { } @Test - public void detachForgetsPendingSurfaces() { + public void aPendingSurfaceWithNoRectanglesIsPrunedNotRetried() { final FakeResolver resolver = new FakeResolver(); final ReactRootOriginTracker tracker = new ReactRootOriginTracker( new FakeLifecycleSource(), new QueueRootFinder(), resolver, new SecureRectangleStore()); @@ -599,6 +602,139 @@ public void detachForgetsPendingSurfaces() { assertEquals(1, resolver.calls); } + private static final int[] WHOLE_FALLBACK = { + 0, 0, SecureRectangleStore.FALLBACK_DISPLAY_SIZE, SecureRectangleStore.FALLBACK_DISPLAY_SIZE }; + + private static int[] served(final SecureRectangleStore store) { + final int[] packed = store.snapshot(0); + return java.util.Arrays.copyOfRange(packed, 2, packed.length); + } + + /** N3: a Modal cleared after its host is gone leaves nothing pending and no lane. */ + @Test + public void clearingAPendingSurfacesRectanglesDropsItAndItsLane() { + final FakeResolver resolver = new FakeResolver(); + final SecureRectangleStore store = new SecureRectangleStore(); + final ReactRootOriginTracker tracker = new ReactRootOriginTracker( + new FakeLifecycleSource(), new QueueRootFinder(), resolver, store); + store.set(0, 56, new int[] { 1, 2, 3, 4 }); + tracker.watchSurfaceNow(56); + tracker.refresh(); + assertEquals(2, resolver.calls); + + store.set(0, 56, new int[0]); + tracker.refresh(); + tracker.refresh(); + + assertEquals(2, resolver.calls); + assertFalse(store.hasSurface(56)); + } + + /** N2: a dialog root seen detached while its surface holds rectangles fails closed and is re-watched. */ + @Test + public void aDetachedDialogRootWithRectanglesFailsClosedAndIsWatchedAgain() { + final FakeResolver resolver = new FakeResolver(); + final FakeRoot first = dialog(56, 0, 0); + resolver.answer = first; + final SecureRectangleStore store = new SecureRectangleStore(); + final ReactRootOriginTracker tracker = new ReactRootOriginTracker( + new FakeLifecycleSource(), new QueueRootFinder(), resolver, store); + store.set(0, 56, new int[] { 208, 560, 608, 760 }); + tracker.watchSurfaceNow(56); + assertArrayEquals(new int[] { 208, 560, 608, 760 }, served(store)); + + // The dialog is recreated: the old root leaves its window, and the + // resolver cannot find the new one yet. + first.attached = false; + resolver.answer = null; + tracker.refresh(); + + assertArrayEquals(WHOLE_FALLBACK, served(store)); + assertEquals(1, first.lastToken.releaseCalls); + assertNull(tracker.cachedSurfaceOrigin(56)); + + // A later refresh finds it again, now at a different place. + resolver.answer = dialog(56, 0, 30); + tracker.refresh(); + + assertArrayEquals(new int[] { 208, 590, 608, 790 }, served(store)); + } + + /** N2: an activity destroyed with a Modal open keeps the surface failing closed, and pending. */ + @Test + public void detachKeepsASurfaceWithRectanglesPendingAndFailingClosed() { + final FakeResolver resolver = new FakeResolver(); + resolver.answer = dialog(56, 0, 0); + final SecureRectangleStore store = new SecureRectangleStore(); + final ReactRootOriginTracker tracker = new ReactRootOriginTracker( + new FakeLifecycleSource(), new QueueRootFinder(), resolver, store); + store.set(0, 56, new int[] { 208, 560, 608, 760 }); + tracker.watchSurfaceNow(56); + + tracker.detach(); + assertArrayEquals(WHOLE_FALLBACK, served(store)); + + resolver.answer = dialog(56, 0, 0); + tracker.refresh(); + assertArrayEquals(new int[] { 208, 560, 608, 760 }, served(store)); + assertEquals(2, resolver.calls); + } + + /** N2: a root that is not attached records no origin; its first layout after attaching does. */ + @Test + public void aRootThatIsNotAttachedRecordsNoOrigin() { + final FakeResolver resolver = new FakeResolver(); + final FakeRoot notYet = dialog(56, 0, 40); + notYet.attached = false; + resolver.answer = notYet; + final SecureRectangleStore store = new SecureRectangleStore(); + final ReactRootOriginTracker tracker = new ReactRootOriginTracker( + new FakeLifecycleSource(), new QueueRootFinder(), resolver, store); + store.set(0, 56, new int[] { 1, 2, 3, 4 }); + + tracker.watchSurfaceNow(56); + assertArrayEquals(WHOLE_FALLBACK, served(store)); + assertNull(tracker.cachedSurfaceOrigin(56)); + assertEquals(1, notYet.listenerRegistrations); + + notYet.attached = true; + tracker.refresh(); + assertArrayEquals(new int[] { 1, 42, 3, 44 }, served(store)); + } + + @Test + public void anActivityRootThatIsNotAttachedRecordsNoOrigin() { + final FakeRoot main = new FakeRoot(); + main.attached = false; + main.location = new int[] { 0, 51 }; + final QueueRootFinder finder = new QueueRootFinder(); + finder.queue.add(main); + final SecureRectangleStore store = new SecureRectangleStore(); + store.set(0, new int[] { 10, 20, 30, 40 }); + + new ReactRootOriginTracker(new FakeLifecycleSource(), finder, store).refresh(); + + assertArrayEquals(WHOLE_FALLBACK, served(store)); + } + + /** N1: the root's display size bounds what a fail-closed surface serves. */ + @Test + public void theRootsDisplaySizeBoundsAFailClosedSurface() { + final FakeRoot main = new FakeRoot(); + main.displaySize = new Point(); + main.displaySize.x = 720; + main.displaySize.y = 1612; + final QueueRootFinder finder = new QueueRootFinder(); + finder.queue.add(main); + final SecureRectangleStore store = new SecureRectangleStore(); + store.set(0, 56, new int[] { 1, 2, 3, 4 }); + + new ReactRootOriginTracker(new FakeLifecycleSource(), finder, store).refresh(); + + assertTrue(store.hasDisplayBounds(0)); + assertArrayEquals(new int[] { 0, 0, 720, 1612 }, served(store)); + } + @Test public void constructorRegistersAsALifecycleListener() { final FakeLifecycleSource lifecycle = new FakeLifecycleSource(); diff --git a/packages/react-native/android/src/test/java/com/bugsee/reactnative/SecureRectangleStoreTest.java b/packages/react-native/android/src/test/java/com/bugsee/reactnative/SecureRectangleStoreTest.java index 4c7a3e3b..548ecabf 100644 --- a/packages/react-native/android/src/test/java/com/bugsee/reactnative/SecureRectangleStoreTest.java +++ b/packages/react-native/android/src/test/java/com/bugsee/reactnative/SecureRectangleStoreTest.java @@ -26,17 +26,29 @@ public class SecureRectangleStoreTest { private static final int DISPLAY = 0; + /** + * A store whose main surface's origin has been read, at (0, 0), on + * displays 0 and 1: what every test about publishing, versions and + * translation starts from. The fail-closed tests below build a bare one. + */ + private static SecureRectangleStore located() { + final SecureRectangleStore store = new SecureRectangleStore(); + store.setOrigin(DISPLAY, 0, 0); + store.setOrigin(1, 0, 0); + return store; + } + /** `[version, count]` with no rectangles, not an empty buffer. */ @Test public void publishesAnEmptySetBeforeAnythingIsSecured() { - final int[] packed = new SecureRectangleStore().snapshot(DISPLAY); + final int[] packed = located().snapshot(DISPLAY); assertEquals(2, packed.length); assertEquals(0, packed[1]); } @Test public void packsVersionCountThenEachRectangle() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); store.set(DISPLAY, new int[] { 1, 2, 3, 4, 10, 20, 30, 40 }); final int[] packed = store.snapshot(DISPLAY); @@ -48,7 +60,7 @@ public void packsVersionCountThenEachRectangle() { @Test public void movesTheVersionWhenTheRectanglesChange() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); final int before = store.snapshot(DISPLAY)[0]; store.set(DISPLAY, new int[] { 1, 2, 3, 4 }); @@ -59,7 +71,7 @@ public void movesTheVersionWhenTheRectanglesChange() { /** Re-publishing the same set must not make the SDK re-read it. */ @Test public void holdsTheVersionWhenNothingChanges() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); store.set(DISPLAY, new int[] { 1, 2, 3, 4 }); final int settled = store.snapshot(DISPLAY)[0]; @@ -75,7 +87,7 @@ public void holdsTheVersionWhenNothingChanges() { */ @Test public void movesTheVersionWhenOnlyTheCoordinatesChange() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); store.set(DISPLAY, new int[] { 1, 2, 3, 4 }); final int before = store.snapshot(DISPLAY)[0]; @@ -87,7 +99,7 @@ public void movesTheVersionWhenOnlyTheCoordinatesChange() { /** Clearing is a change like any other: the last secret region must stop. */ @Test public void movesTheVersionWhenTheLastRectangleIsRemoved() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); store.set(DISPLAY, new int[] { 1, 2, 3, 4 }); final int before = store.snapshot(DISPLAY)[0]; @@ -100,7 +112,7 @@ public void movesTheVersionWhenTheLastRectangleIsRemoved() { /** The freshness clock is per display, so one screen cannot stale another. */ @Test public void versionsEachDisplayIndependently() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); final int otherBefore = store.snapshot(1)[0]; store.set(DISPLAY, new int[] { 1, 2, 3, 4 }); @@ -116,7 +128,7 @@ public void versionsEachDisplayIndependently() { */ @Test public void handsOutASnapshotThatCannotAliasTheStore() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); store.set(DISPLAY, new int[] { 1, 2, 3, 4 }); final int[] first = store.snapshot(DISPLAY); @@ -129,7 +141,7 @@ public void handsOutASnapshotThatCannotAliasTheStore() { /** Callers cannot keep writing through the array they handed us either. */ @Test public void copiesWhatItIsGiven() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); final int[] mutable = { 1, 2, 3, 4 }; store.set(DISPLAY, mutable); @@ -145,7 +157,7 @@ public void copiesWhatItIsGiven() { */ @Test public void rejectsACoordinateListThatIsNotWholeRectangles() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); final IllegalArgumentException thrown = assertThrows( IllegalArgumentException.class, () -> store.set(DISPLAY, new int[] { 1, 2, 3 })); @@ -155,7 +167,7 @@ public void rejectsACoordinateListThatIsNotWholeRectangles() { @Test public void rejectsNullCoordinates() { assertThrows(IllegalArgumentException.class, - () -> new SecureRectangleStore().set(DISPLAY, null)); + () -> located().set(DISPLAY, null)); } // ---- Display origin ------------------------------------------------- @@ -169,7 +181,7 @@ public void rejectsNullCoordinates() { @Test public void translatesEveryRectangleByTheDisplayOrigin() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); store.setOrigin(DISPLAY, 7, 96); store.set(DISPLAY, new int[] { 10, 20, 30, 40, 50, 60, 70, 80 }); @@ -187,7 +199,7 @@ public void translatesEveryRectangleByTheDisplayOrigin() { */ @Test public void anOriginChangeMovesPublishedRectanglesAndTheVersion() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); store.set(DISPLAY, new int[] { 10, 20, 30, 40 }); final int before = store.snapshot(DISPLAY)[0]; @@ -202,7 +214,7 @@ public void anOriginChangeMovesPublishedRectanglesAndTheVersion() { /** The origin is re-reported on every layout pass; an unchanged one is free. */ @Test public void anUnchangedOriginHoldsTheVersion() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); store.setOrigin(DISPLAY, 0, 96); store.set(DISPLAY, new int[] { 10, 20, 30, 40 }); final int settled = store.snapshot(DISPLAY)[0]; @@ -215,7 +227,7 @@ public void anUnchangedOriginHoldsTheVersion() { /** A republish after an origin change keeps the origin, not (0, 0). */ @Test public void keepsTheOriginAcrossRepublishes() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); store.setOrigin(DISPLAY, 0, 96); store.set(DISPLAY, new int[] { 1, 2, 3, 4 }); store.set(DISPLAY, new int[] { 10, 20, 30, 40 }); @@ -227,7 +239,7 @@ public void keepsTheOriginAcrossRepublishes() { @Test public void anOriginAppliesOnlyToItsOwnDisplay() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); store.setOrigin(1, 0, 96); store.set(DISPLAY, new int[] { 10, 20, 30, 40 }); @@ -239,7 +251,7 @@ public void anOriginAppliesOnlyToItsOwnDisplay() { /** An origin with nothing secured serves the empty set, at the empty version. */ @Test public void anOriginAloneSecuresNothing() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); final int[] empty = store.snapshot(DISPLAY); store.setOrigin(DISPLAY, 0, 96); @@ -250,7 +262,7 @@ public void anOriginAloneSecuresNothing() { /** Past int32 the sum saturates outwards rather than wrapping elsewhere. */ @Test public void saturatesOutwardsInsteadOfWrapping() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); store.setOrigin(DISPLAY, -10, 10); store.set(DISPLAY, new int[] { Integer.MIN_VALUE + 5, 0, 0, Integer.MAX_VALUE - 5 }); @@ -290,7 +302,7 @@ public void theDisplayOriginIsTheScreenLocationLessTheViewportOffset() { @Test public void theMainOriginDoesNotMoveAnotherSurfacesRectangle() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); store.set(DISPLAY, MAIN_SURFACE, new int[] { 10, 20, 30, 40 }); store.set(DISPLAY, MODAL_SURFACE, new int[] { 100, 200, 150, 250 }); store.setOrigin(DISPLAY, MAIN_SURFACE, 0, 96); @@ -305,7 +317,7 @@ public void theMainOriginDoesNotMoveAnotherSurfacesRectangle() { @Test public void anotherSurfacesOwnOriginMovesOnlyItsRectangle() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); store.set(DISPLAY, MAIN_SURFACE, new int[] { 10, 20, 30, 40 }); store.set(DISPLAY, MODAL_SURFACE, new int[] { 100, 200, 150, 250 }); store.setOrigin(DISPLAY, MAIN_SURFACE, 0, 96); @@ -318,23 +330,99 @@ public void anotherSurfacesOwnOriginMovesOnlyItsRectangle() { java.util.Arrays.copyOfRange(packed, 2, packed.length)); } - // ---- Failing closed ------------------------------------------------- + // ---- Failing closed, and the display's bounds ------------------------ + + private static final int WIDTH = 720; + private static final int HEIGHT = 1612; + private static final int[] WHOLE_DISPLAY = { 0, 0, WIDTH, HEIGHT }; + + private static int[] rectanglesOf(final int[] packed) { + return java.util.Arrays.copyOfRange(packed, 2, packed.length); + } - /** A 's rectangles before its origin is read redact the whole display. */ + /** A 's rectangles before its origin is read redact exactly the whole display. */ @Test - public void aSurfaceWithNoOriginYetServesTheWholeDisplay() { + public void aSurfaceWithNoOriginYetServesTheDisplayBounds() { final SecureRectangleStore store = new SecureRectangleStore(); + store.setDisplayBounds(DISPLAY, WIDTH, HEIGHT); store.set(DISPLAY, MODAL_SURFACE, new int[] { 100, 200, 150, 250, 1, 2, 3, 4 }); final int[] packed = store.snapshot(DISPLAY); assertEquals(1, packed[1]); - assertArrayEquals(SecureRectangleStore.FULL_DISPLAY, - java.util.Arrays.copyOfRange(packed, 2, packed.length)); + assertArrayEquals(WHOLE_DISPLAY, rectanglesOf(packed)); + } + + /** N4: the main surface fails closed too, until its first origin read. */ + @Test + public void theMainSurfaceWithNoOriginYetServesTheDisplayBounds() { + final SecureRectangleStore store = new SecureRectangleStore(); + store.setDisplayBounds(DISPLAY, WIDTH, HEIGHT); + store.set(DISPLAY, new int[] { 10, 20, 30, 40 }); + + assertArrayEquals(WHOLE_DISPLAY, rectanglesOf(store.snapshot(DISPLAY))); + + store.setOrigin(DISPLAY, 0, 51); + assertArrayEquals(new int[] { 10, 71, 30, 91 }, rectanglesOf(store.snapshot(DISPLAY))); + } + + @Test + public void withoutTheDisplaysSizeTheWholeDisplayIsTheFallbackSquare() { + final SecureRectangleStore store = new SecureRectangleStore(); + store.set(DISPLAY, MODAL_SURFACE, new int[] { 1, 2, 3, 4 }); + + final int size = SecureRectangleStore.FALLBACK_DISPLAY_SIZE; + assertArrayEquals(new int[] { 0, 0, size, size }, rectanglesOf(store.snapshot(DISPLAY))); + } + + @Test + public void recordingTheDisplaysSizeShrinksTheWholeDisplayRectangleAndMovesTheVersion() { + final SecureRectangleStore store = new SecureRectangleStore(); + store.set(DISPLAY, MODAL_SURFACE, new int[] { 1, 2, 3, 4 }); + final int before = store.snapshot(DISPLAY)[0]; + + store.setDisplayBounds(DISPLAY, WIDTH, HEIGHT); + + final int[] packed = store.snapshot(DISPLAY); + assertNotEquals(before, packed[0]); + assertArrayEquals(WHOLE_DISPLAY, rectanglesOf(packed)); + assertTrue(store.hasDisplayBounds(DISPLAY)); + assertFalse(store.hasDisplayBounds(1)); + } + + @Test + public void recordingTheSameSizeAgainHoldsTheVersion() { + final SecureRectangleStore store = located(); + store.set(DISPLAY, new int[] { 1, 2, 3, 4 }); + store.setDisplayBounds(DISPLAY, WIDTH, HEIGHT); + final int settled = store.snapshot(DISPLAY)[0]; + + store.setDisplayBounds(DISPLAY, WIDTH, HEIGHT); + store.setDisplayBounds(DISPLAY, 0, HEIGHT); + + assertEquals(settled, store.snapshot(DISPLAY)[0]); + } + + /** N1: a renderer is never handed a rectangle that runs off the panel. */ + @Test + public void everyServedRectangleIsClampedToTheDisplay() { + final SecureRectangleStore store = located(); + store.setDisplayBounds(DISPLAY, WIDTH, HEIGHT); + store.set(DISPLAY, new int[] { + -10, -20, 100, 200, // off the top left: clamped + 600, 1500, 900, 1800, // off the bottom right: clamped + 800, 100, 900, 200, // wholly off the right: dropped + 10, 10, 10, 50, // no width: dropped + }); + + final int[] packed = store.snapshot(DISPLAY); + assertEquals(2, packed[1]); + assertArrayEquals(new int[] { 0, 0, 100, 200, 600, 1500, WIDTH, HEIGHT }, rectanglesOf(packed)); } @Test public void readingTheSurfacesOriginReplacesTheWholeDisplayRectangle() { final SecureRectangleStore store = new SecureRectangleStore(); + store.setDisplayBounds(DISPLAY, WIDTH, HEIGHT); store.set(DISPLAY, MODAL_SURFACE, new int[] { 100, 200, 150, 250 }); final int before = store.snapshot(DISPLAY)[0]; @@ -342,18 +430,46 @@ public void readingTheSurfacesOriginReplacesTheWholeDisplayRectangle() { final int[] packed = store.snapshot(DISPLAY); assertNotEquals(before, packed[0]); - assertArrayEquals(new int[] { 100, 200, 150, 250 }, - java.util.Arrays.copyOfRange(packed, 2, packed.length)); + assertArrayEquals(new int[] { 100, 200, 150, 250 }, rectanglesOf(packed)); } - /** The main surface keeps (0, 0) until its origin is read, as before surfaces existed. */ + /** N2: a forgotten root's surface fails closed again until a new origin is read. */ @Test - public void theMainSurfaceWithNoOriginServesItsRectanglesUnmoved() { - final SecureRectangleStore store = new SecureRectangleStore(); - store.set(DISPLAY, new int[] { 10, 20, 30, 40 }); + public void forgettingASurfacesOriginServesTheWholeDisplayAgain() { + final SecureRectangleStore store = located(); + store.setDisplayBounds(DISPLAY, WIDTH, HEIGHT); + store.set(DISPLAY, MODAL_SURFACE, new int[] { 100, 200, 150, 250 }); + store.setOrigin(DISPLAY, MODAL_SURFACE, 0, 0); - assertArrayEquals(new int[] { 10, 20, 30, 40 }, - java.util.Arrays.copyOfRange(store.snapshot(DISPLAY), 2, 6)); + store.forgetOrigin(MODAL_SURFACE); + assertArrayEquals(WHOLE_DISPLAY, rectanglesOf(store.snapshot(DISPLAY))); + + store.setOrigin(DISPLAY, MODAL_SURFACE, 0, 30); + assertArrayEquals(new int[] { 100, 230, 150, 280 }, rectanglesOf(store.snapshot(DISPLAY))); + } + + @Test + public void forgettingAnEmptyOrUnknownSurfaceChangesNothing() { + final SecureRectangleStore store = located(); + store.set(DISPLAY, new int[] { 1, 2, 3, 4 }); + store.set(DISPLAY, MODAL_SURFACE, new int[0]); + final int[] before = store.snapshot(DISPLAY); + + store.forgetOrigin(MODAL_SURFACE); + store.forgetOrigin(99); + + assertArrayEquals(before, store.snapshot(DISPLAY)); + } + + @Test + public void hasRectanglesIsTrueOnlyForASurfaceThatHoldsSome() { + final SecureRectangleStore store = located(); + store.set(DISPLAY, MODAL_SURFACE, new int[] { 1, 2, 3, 4 }); + store.set(DISPLAY, 43, new int[0]); + + assertTrue(store.hasRectangles(MODAL_SURFACE)); + assertFalse(store.hasRectangles(43)); + assertFalse(store.hasRectangles(99)); } @Test @@ -366,7 +482,7 @@ public void anEmptySurfaceWithNoOriginServesNothing() { @Test public void dropSurfaceIfEmptyForgetsOnlyAnEmptyNonMainLane() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); store.set(DISPLAY, MODAL_SURFACE, new int[0]); store.set(DISPLAY, 43, new int[] { 1, 2, 3, 4 }); store.set(DISPLAY, MAIN_SURFACE, new int[0]); @@ -383,7 +499,7 @@ public void dropSurfaceIfEmptyForgetsOnlyAnEmptyNonMainLane() { /** Existing single-argument set/setOrigin keep meaning the main surface. */ @Test public void theLegacySetAndSetOriginTargetTheMainSurface() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); store.set(DISPLAY, new int[] { 10, 20, 30, 40 }); store.setOrigin(DISPLAY, 0, 96); @@ -400,7 +516,7 @@ public void theLegacySetAndSetOriginTargetTheMainSurface() { @Test public void publishOrLogRejectsABadListWithoutThrowingOrTouchingTheSet() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); store.set(DISPLAY, new int[] { 1, 2, 3, 4 }); final int[] before = store.snapshot(DISPLAY); @@ -412,7 +528,7 @@ public void publishOrLogRejectsABadListWithoutThrowingOrTouchingTheSet() { @Test public void publishOrLogPublishesAGoodList() { - final SecureRectangleStore store = new SecureRectangleStore(); + final SecureRectangleStore store = located(); assertTrue(store.publishOrLog(DISPLAY, new int[] { 1, 2, 3, 4 })); diff --git a/packages/react-native/ios/BugseeModule.mm b/packages/react-native/ios/BugseeModule.mm index e7851083..bc408fa3 100644 --- a/packages/react-native/ios/BugseeModule.mm +++ b/packages/react-native/ios/BugseeModule.mm @@ -118,6 +118,8 @@ - (NSData *)secureRectanglesForDisplay:(NSInteger)display { // is not found yet keeps failing closed. if (NSThread.isMainThread) { BGSRNSecureRectangles *store = BGSRNSecureRectangles.shared; + // What a surface whose origin is unknown serves: the screen, not more. + [store setDisplaySize:UIScreen.mainScreen.bounds.size forDisplay:display]; NSValue *origin = BGSRNReactOrigin(); if (origin != nil) { [store setOrigin:origin.CGPointValue forDisplay:display]; @@ -315,6 +317,10 @@ - (UIViewController *)viewController; /// 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 the Modal is not presented, or off main. +/// +/// 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. static NSValue *_Nullable BGSRNModalSurfaceOrigin(NSInteger surface) { if (!NSThread.isMainThread) { return nil; diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m index ae6957e4..05f16baa 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m @@ -12,7 +12,7 @@ const NSInteger BGSRNSecureMainSurface = 0; -const int32_t BGSRNSecureFullDisplay[4] = {0, 0, 1 << 20, 1 << 20}; +const int32_t BGSRNSecureFallbackDisplaySize = 16384; /// Steps the version, skipping the value that means "nothing secured yet": /// landing back on it would make a real set look identical to the empty one to @@ -67,22 +67,14 @@ @interface BGSRNSecureLane : NSObject @implementation BGSRNSecureLane -- (instancetype)initWithRaw:(NSData *)raw - origin:(CGPoint)origin - originKnown:(BOOL)originKnown - failClosed:(BOOL)failClosed { +- (instancetype)initWithRaw:(NSData *)raw origin:(CGPoint)origin originKnown:(BOOL)originKnown { self = [super init]; if (self) { _raw = [raw copy] ?: [NSData data]; _origin = origin; _originKnown = originKnown; - if (_raw.length == 0) { - _served = [NSData data]; - } else if (!originKnown && failClosed) { - _served = [NSData dataWithBytes:BGSRNSecureFullDisplay length:sizeof(BGSRNSecureFullDisplay)]; - } else { - _served = BGSRNMovedCoordinates(_raw, origin); - } + // An unknown origin is served as the whole display, sized at serve time. + _served = _raw.length == 0 || !originKnown ? [NSData data] : BGSRNMovedCoordinates(_raw, origin); } return self; } @@ -92,6 +84,8 @@ - (instancetype)initWithRaw:(NSData *)raw @implementation BGSRNSecureRectangles { /// display -> (surface -> lane). Served in surface-key order. NSMutableDictionary *> *_lanesByDisplay; + /// display -> its screen's size in points, a CGSize, once recorded. + NSMutableDictionary *_boundsByDisplay; /// display -> its current version. Absent until the served set first changes. NSMutableDictionary *_versionsByDisplay; /// Serialises the JS-thread write against the main-thread pull. A plain lock @@ -114,6 +108,7 @@ - (instancetype)init { if (self) { _lanesByDisplay = [NSMutableDictionary dictionary]; _versionsByDisplay = [NSMutableDictionary dictionary]; + _boundsByDisplay = [NSMutableDictionary dictionary]; _lock = [[NSLock alloc] init]; } return self; @@ -145,11 +140,10 @@ - (BOOL)setCoordinates:(const int32_t *)coordinates [_lock lock]; [self changeLaneLockedForDisplay:display surface:surface - change:^BGSRNSecureLane *(BGSRNSecureLane *prior, BOOL failClosed) { + change:^BGSRNSecureLane *(BGSRNSecureLane *prior) { return [[BGSRNSecureLane alloc] initWithRaw:published origin:prior.origin - originKnown:prior.originKnown - failClosed:failClosed]; + originKnown:prior.originKnown]; }]; [_lock unlock]; @@ -170,15 +164,31 @@ - (void)setOrigin:(CGPoint)origin forDisplay:(NSInteger)display surface:(NSInteg } [self changeLaneLockedForDisplay:display surface:surface - change:^BGSRNSecureLane *(BGSRNSecureLane *prior, BOOL failClosed) { + change:^BGSRNSecureLane *(BGSRNSecureLane *prior) { return [[BGSRNSecureLane alloc] initWithRaw:prior.raw origin:origin - originKnown:YES - failClosed:failClosed]; + originKnown:YES]; }]; [_lock unlock]; } +- (void)setDisplaySize:(CGSize)size forDisplay:(NSInteger)display { + if (!(size.width > 0) || !(size.height > 0)) { + return; + } + NSNumber *key = @(display); + [_lock lock]; + CGSize previous = CGSizeZero; + NSValue *recorded = _boundsByDisplay[key]; + [recorded getValue:&previous size:sizeof(previous)]; + if (recorded == nil || !CGSizeEqualToSize(previous, size)) { + NSData *previousServed = [self servedLockedForDisplay:display]; + _boundsByDisplay[key] = [NSValue valueWithBytes:&size objCType:@encode(CGSize)]; + [self moveVersionLockedForDisplay:display ifServedDiffersFrom:previousServed]; + } + [_lock unlock]; +} + - (NSArray *)surfacesForDisplay:(NSInteger)display { [_lock lock]; NSMutableArray *surfaces = [NSMutableArray array]; @@ -210,7 +220,7 @@ - (void)dropSurfaceIfEmpty:(NSInteger)surface { /// changes. Caller holds `_lock`. - (void)changeLaneLockedForDisplay:(NSInteger)display surface:(NSInteger)surface - change:(BGSRNSecureLane * (^)(BGSRNSecureLane *prior, BOOL failClosed))change { + change:(BGSRNSecureLane * (^)(BGSRNSecureLane *prior))change { NSNumber *displayKey = @(display); NSNumber *surfaceKey = @(surface); NSData *previousServed = [self servedLockedForDisplay:display]; @@ -219,13 +229,16 @@ - (void)changeLaneLockedForDisplay:(NSInteger)display lanes = [NSMutableDictionary dictionary]; _lanesByDisplay[displayKey] = lanes; } - const BOOL failClosed = surface != BGSRNSecureMainSurface; BGSRNSecureLane *prior = lanes[surfaceKey] - ?: [[BGSRNSecureLane alloc] initWithRaw:[NSData data] - origin:CGPointZero - originKnown:NO - failClosed:failClosed]; - lanes[surfaceKey] = change(prior, failClosed); + ?: [[BGSRNSecureLane alloc] initWithRaw:[NSData data] origin:CGPointZero originKnown:NO]; + lanes[surfaceKey] = change(prior); + [self moveVersionLockedForDisplay:display ifServedDiffersFrom:previousServed]; +} + +/// Steps the display's version when what it serves now differs from +/// `previousServed`. Caller holds `_lock`. +- (void)moveVersionLockedForDisplay:(NSInteger)display ifServedDiffersFrom:(NSData *)previousServed { + NSNumber *displayKey = @(display); NSData *nextServed = [self servedLockedForDisplay:display]; if (![previousServed isEqualToData:nextServed]) { const int32_t currentVersion = _versionsByDisplay[displayKey] @@ -235,15 +248,30 @@ - (void)changeLaneLockedForDisplay:(NSInteger)display } } -/// Every lane's served coordinates, in surface-key order. Caller holds `_lock`. +/// Every lane's served coordinates, in surface-key order. A lane whose origin +/// is unknown serves one rectangle covering the display: its screen's size, +/// rounded up, or the fallback square before that is recorded. Caller holds +/// `_lock`. - (NSData *)servedLockedForDisplay:(NSInteger)display { NSMutableDictionary *lanes = _lanesByDisplay[@(display)]; if (lanes.count == 0) { return [NSData data]; } + NSValue *recorded = _boundsByDisplay[@(display)]; + CGSize size = CGSizeMake(BGSRNSecureFallbackDisplaySize, BGSRNSecureFallbackDisplaySize); + [recorded getValue:&size size:sizeof(size)]; + const int32_t whole[4] = {0, 0, (int32_t)ceil(size.width), (int32_t)ceil(size.height)}; NSMutableData *merged = [NSMutableData data]; for (NSNumber *key in [lanes.allKeys sortedArrayUsingSelector:@selector(compare:)]) { - [merged appendData:lanes[key].served]; + BGSRNSecureLane *lane = lanes[key]; + if (lane.raw.length == 0) { + continue; + } + if (lane.originKnown) { + [merged appendData:lane.served]; + } else { + [merged appendBytes:whole length:sizeof(whole)]; + } } return [merged copy]; } 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 7d12673e..5d6e7b49 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSecureRectangles.h +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSecureRectangles.h @@ -7,9 +7,9 @@ NS_ASSUME_NONNULL_BEGIN /// surface land here. FOUNDATION_EXPORT const NSInteger BGSRNSecureMainSurface; -/// What a surface whose origin is unknown serves in place of its rectangles: -/// one rectangle larger than any screen, `{0, 0, 2^20, 2^20}`. -FOUNDATION_EXPORT const int32_t BGSRNSecureFullDisplay[4]; +/// The side of the square a surface whose origin is unknown serves before its +/// display's size is recorded (`setDisplaySize:forDisplay:`). +FOUNDATION_EXPORT const int32_t BGSRNSecureFallbackDisplaySize; /// The regions the app has asked Bugsee not to record, in the form the SDK /// pulls them. @@ -43,8 +43,9 @@ FOUNDATION_EXPORT const int32_t BGSRNSecureFullDisplay[4]; /// right and bottom ones up: a rectangle may grow by under a point, never /// shrink. Edges saturate at the int32 range rather than wrap. /// -/// Fails closed: a surface other than the main one whose origin has not been -/// recorded yet serves `BGSRNSecureFullDisplay` instead of its rectangles. +/// Fails closed: a surface whose origin has not been recorded yet, the main +/// one included, serves one rectangle covering its display (the screen's +/// bounds) instead of its rectangles. /// /// ## Threading /// @@ -87,6 +88,10 @@ FOUNDATION_EXPORT const int32_t BGSRNSecureFullDisplay[4]; /// screen, in points. Only that surface's rectangles move. - (void)setOrigin:(CGPoint)origin forDisplay:(NSInteger)display surface:(NSInteger)surface; +/// Records `display`'s screen size in points: what a surface whose origin is +/// unknown serves. Re-recording the same size costs nothing. +- (void)setDisplaySize:(CGSize)size forDisplay:(NSInteger)display; + /// The surfaces other than the main one that `display` has lanes for, in key /// order: the ones whose origin the pull refreshes. - (NSArray *)surfacesForDisplay:(NSInteger)display; diff --git a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m index 93759139..a14213f1 100644 --- a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m +++ b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m @@ -19,6 +19,11 @@ @implementation BGSRNSecureRectanglesTests { - (void)setUp { [super setUp]; _store = [[BGSRNSecureRectangles alloc] init]; + // The main surface's origin read, as the first pull does: every test about + // publishing and versions starts here. The fail-closed tests use a bare + // store. + [_store setOrigin:CGPointZero forDisplay:0]; + [_store setOrigin:CGPointZero forDisplay:1]; } /// Reads the buffer as the SDK does: little-endian int32, `[version, count, โ€ฆ]`. @@ -186,15 +191,46 @@ - (void)testAnotherSurfacesOwnOriginMovesOnlyItsRectangle { (@[ @10, @116, @30, @136, @107, @240, @157, @290 ])); } -/// A Modal's rectangles before its origin is recorded redact the whole screen. -- (void)testASurfaceWithNoOriginYetServesTheWholeDisplay { +/// A Modal's rectangles before its origin is recorded redact exactly the screen. +- (void)testASurfaceWithNoOriginYetServesTheScreenBounds { const int32_t modalRects[] = {100, 200, 150, 250, 1, 2, 3, 4}; + [_store setDisplaySize:CGSizeMake(402, 874) forDisplay:0]; [_store setCoordinates:modalRects count:8 forDisplay:0 surface:42]; NSArray *packed = [self unpack:[_store snapshotForDisplay:0]]; XCTAssertEqualObjects(packed[1], @1); - XCTAssertEqualObjects([packed subarrayWithRange:NSMakeRange(2, 4)], - (@[ @0, @0, @(1 << 20), @(1 << 20) ])); + XCTAssertEqualObjects([packed subarrayWithRange:NSMakeRange(2, 4)], (@[ @0, @0, @402, @874 ])); +} + +- (void)testAFractionalScreenSizeIsRoundedUp { + const int32_t rects[] = {1, 2, 3, 4}; + [_store setDisplaySize:CGSizeMake(402.5, 873.2) forDisplay:0]; + [_store setCoordinates:rects count:4 forDisplay:0 surface:42]; + + XCTAssertEqualObjects([[self unpack:[_store snapshotForDisplay:0]] subarrayWithRange:NSMakeRange(2, 4)], + (@[ @0, @0, @403, @874 ])); +} + +- (void)testBeforeTheScreenSizeIsKnownTheWholeDisplayIsTheFallbackSquare { + const int32_t rects[] = {1, 2, 3, 4}; + [_store setCoordinates:rects count:4 forDisplay:0 surface:42]; + + XCTAssertEqualObjects([[self unpack:[_store snapshotForDisplay:0]] subarrayWithRange:NSMakeRange(2, 4)], + (@[ @0, @0, @(BGSRNSecureFallbackDisplaySize), @(BGSRNSecureFallbackDisplaySize) ])); +} + +- (void)testRecordingTheScreenSizeMovesTheVersionOnlyWhenWhatIsServedChanges { + const int32_t rects[] = {1, 2, 3, 4}; + [_store setCoordinates:rects count:4 forDisplay:0 surface:42]; + NSNumber *before = [self unpack:[_store snapshotForDisplay:0]][0]; + + [_store setDisplaySize:CGSizeMake(402, 874) forDisplay:0]; + NSNumber *sized = [self unpack:[_store snapshotForDisplay:0]][0]; + [_store setDisplaySize:CGSizeMake(402, 874) forDisplay:0]; + [_store setDisplaySize:CGSizeZero forDisplay:0]; + + XCTAssertNotEqualObjects(sized, before); + XCTAssertEqualObjects([self unpack:[_store snapshotForDisplay:0]][0], sized); } - (void)testRecordingTheSurfacesOriginReplacesTheWholeDisplayRectangle { @@ -209,13 +245,19 @@ - (void)testRecordingTheSurfacesOriginReplacesTheWholeDisplayRectangle { XCTAssertEqualObjects([packed subarrayWithRange:NSMakeRange(2, 4)], (@[ @100, @200, @150, @250 ])); } -/// The main surface keeps (0, 0) until its origin is recorded. -- (void)testTheMainSurfaceWithNoOriginServesItsRectanglesUnmoved { +/// The main surface fails closed too, until its first origin is recorded. +- (void)testTheMainSurfaceWithNoOriginYetServesTheScreenBounds { + BGSRNSecureRectangles *bare = [[BGSRNSecureRectangles alloc] init]; const int32_t rects[] = {10, 20, 30, 40}; - [_store setCoordinates:rects count:4 forDisplay:0]; + [bare setDisplaySize:CGSizeMake(402, 874) forDisplay:0]; + [bare setCoordinates:rects count:4 forDisplay:0]; - XCTAssertEqualObjects([[self unpack:[_store snapshotForDisplay:0]] subarrayWithRange:NSMakeRange(2, 4)], - (@[ @10, @20, @30, @40 ])); + XCTAssertEqualObjects([[self unpack:[bare snapshotForDisplay:0]] subarrayWithRange:NSMakeRange(2, 4)], + (@[ @0, @0, @402, @874 ])); + + [bare setOrigin:CGPointMake(0, 59) forDisplay:0]; + XCTAssertEqualObjects([[self unpack:[bare snapshotForDisplay:0]] subarrayWithRange:NSMakeRange(2, 4)], + (@[ @10, @79, @30, @99 ])); } /// A fractional origin grows the rectangle by under a point, never shrinks it. From b349408132966593f534c98f9ef3d2ff7c3ac178 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Mon, 5 Oct 2026 12:46:43 +0500 Subject: [PATCH 10/19] test(e2e): check the Modal's secure view in every video frame too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-review fix round 2 (N6). The Modal holds a red witness view, not secure. On targets with a device screenshot, the test finds the witness there, crops it and the Modal's secure view from every interior video frame (letterbox mapped and checked against video.aux on Android, width-fit on iOS, as secure-component.test.ts does), and requires the secure view to be dark in every frame that shows the witness, with the Modal still up in the last frame. Both crops come from the same frames, so the video's timestamp lag does not matter. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --- examples/bare/e2e/secure-modal.test.ts | 85 +++++++++++++++++++++++++- examples/bare/scenarios/privacy.tsx | 8 +++ 2 files changed, 90 insertions(+), 3 deletions(-) diff --git a/examples/bare/e2e/secure-modal.test.ts b/examples/bare/e2e/secure-modal.test.ts index f6450f49..7f5c2cbb 100644 --- a/examples/bare/e2e/secure-modal.test.ts +++ b/examples/bare/e2e/secure-modal.test.ts @@ -21,6 +21,12 @@ * sheet's colour is on screen. The iPhone has no command-line screenshot, so * there only the colour check runs. * + * The video is checked too (the SDK masks it on a separate, native path): a + * witness inside the Modal, red and not secure, marks every frame that shows + * the Modal, and in each of those the Modal's secure view must be dark. Both + * crops come from the same frames, so the video's timestamp lag does not + * matter. + * * Android: WOD_LX1 with `E2E_EDGE_TO_EDGE` naming the installed build. * iOS: simulator or XS (`E2E_IOS_TARGET`). */ @@ -29,7 +35,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { promisify } from 'node:util'; -import { type PulledBundle, airplane, removePulledBundles } from './bundles'; +import { type PulledBundle, airplane, captureEvents, removePulledBundles } from './bundles'; import { ADB, ANDROID_SERIAL, IOS_SIMULATOR_ID, iosTarget } from './device'; import { ON_IOS, @@ -46,7 +52,18 @@ import { stopApp, stopDeviceLog, } from './harness'; -import { LUMA_BRIGHT_MIN, LUMA_DARK_MAX, colourOf, colourPixels } from './media'; +import { + LUMA_BRIGHT_MIN, + LUMA_DARK_MAX, + colourOf, + colourPixels, + frameLumas, + imageSize, + letterbox, + regionFrameLumas, + videoRegion, + widthFitRegion, +} from './media'; import { type DeviceLog, type LogLine, adb, resetScenario } from './scenario'; import { type Media, @@ -55,6 +72,7 @@ import { boundsIn, bundleBySummary, displaySize, + interiorFrames, markerScreen, moved, screenshotLumas, @@ -69,6 +87,11 @@ jest.setTimeout(4 * 60_000); const SHEET_LABEL = 'bugsee-secure-modal-sheet'; const MAIN_COLOUR = colourOf('#FF00FF'); const SHEET_COLOUR = colourOf('#00FFFF'); +const WITNESS_COLOUR = colourOf('#FF0000'); +/** Video pixels trimmed off every edge of a crop, as in secure-component.test.ts. */ +const VIDEO_INSET = 3; +/** The fewest witnessed frames a video check may rest on. */ +const MIN_FRAMES = 5; /** Pixels of a secure colour a report may show before it counts as a leak (antialiased edges). */ const LEAK_MAX_PIXELS = 64; /** A secure view the device screenshot shows must be at least this big to count as found. */ @@ -113,6 +136,8 @@ for (const scenario of SCENARIOS) { let deviceShot: string | null; /** Where the device shows each secure view (display units); `main` is absent behind an opaque sheet. */ let found: { main: Rect | null; sheet: Rect } | null = null; + /** Where the device shows the Modal's red witness (display units). */ + let witness: Rect | null = null; let sheetFromDump: Rect | undefined; let mainFromJs: Rect | undefined; let servedLine: LogLine | undefined; @@ -159,7 +184,11 @@ for (const scenario of SCENARIOS) { if (deviceShot !== null) { const main = await colourPixels(deviceShot, MAIN_COLOUR); const sheet = await colourPixels(deviceShot, SHEET_COLOUR); - report('device screenshot', { main, sheet }); + const red = await colourPixels(deviceShot, WITNESS_COLOUR); + report('device screenshot', { main, sheet, witness: red }); + if (red.box !== null) { + witness = toDisplay(red.box, red.width, display.width); + } if (sheet.box !== null) { found = { main: main.box === null ? null : toDisplay(main.box, main.width, display.width), @@ -298,5 +327,55 @@ for (const scenario of SCENARIOS) { expect(served.rects.some(r => covers(r, sheetFromDump as Rect))).toBe(true); }); } + it('every video frame that shows the Modal is masked over its secure view', async () => { + if (deviceShot === null) { + report('video', 'no device screenshot on this target to place the witness: skipped'); + return; + } + if (found === null || witness === null) { + throw new Error("the device screenshot does not show the Modal's secure colour and its witness"); + } + const video = media.video; + const size = await imageSize(video); + 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([]); + region = rect => widthFitRegion(rect, display, size, VIDEO_INSET); + } else { + const box = letterbox(display, size); + const aux = captureEvents(bundle, 'video.aux')[0] as + | { paddingH?: number; paddingV?: number; screenW?: number; screenH?: number } + | undefined; + report('video letterbox', { size, derived: box, aux }); + expect(aux).toBeDefined(); + expect({ screenW: aux!.screenW, screenH: aux!.screenH }).toEqual({ screenW: display.width, screenH: display.height }); + expect(Math.abs(box.padH - aux!.paddingH!)).toBeLessThanOrEqual(1); + expect(Math.abs(box.padV - aux!.paddingV!)).toBeLessThanOrEqual(1); + region = rect => videoRegion(rect, display, size, VIDEO_INSET); + } + const centre = await frameLumas(video); + const { from, to } = interiorFrames(centre); + const series = async (rect: Rect): Promise => { + const frames = await regionFrameLumas(video, region(rect)); + // The same decode, frame for frame: the pairing below is by index. + expect(frames.map(f => f.t)).toEqual(centre.map(f => f.t)); + return frames.slice(from, to).map(f => Math.round(f.luma)); + }; + const t = centre.slice(from, to).map(f => f.t); + const witnessLuma = await series(witness); + const sheetLuma = await series(found.sheet); + report('video frames (t, witness, sheet)', t.map((time, i) => [time, witnessLuma[i], sheetLuma[i]])); + + // The witness is white stage before the Modal shows, and red (or black, + // under a whole-display mask while an origin is unknown) once it does. + // Every frame that shows it must hide the secure view, and the Modal is + // still up when the report is taken. + const shown = witnessLuma.map((l, i) => i).filter(i => witnessLuma[i]! < LUMA_BRIGHT_MIN); + expect(shown.length).toBeGreaterThanOrEqual(MIN_FRAMES); + expect(shown[shown.length - 1]).toBe(witnessLuma.length - 1); + const leaked = shown.map(i => ({ t: t[i], l: sheetLuma[i]! })).filter(frame => frame.l > LUMA_DARK_MAX); + expect(leaked).toEqual([]); + }); }); } diff --git a/examples/bare/scenarios/privacy.tsx b/examples/bare/scenarios/privacy.tsx index ad04df89..1e280a75 100644 --- a/examples/bare/scenarios/privacy.tsx +++ b/examples/bare/scenarios/privacy.tsx @@ -82,6 +82,13 @@ export const SECURE_MODAL_SHEET_LABEL = 'bugsee-secure-modal-sheet'; */ export const SECURE_MODAL_MAIN_COLOUR = '#FF00FF'; export const SECURE_MODAL_SHEET_COLOUR = '#00FFFF'; +/** + * A witness inside the Modal, not secure, in a colour of its own: a video + * frame that shows it shows the Modal, so the frames the test checks the + * Modal's secure view in are found from the video itself. + */ +export const SECURE_MODAL_WITNESS_COLOUR = '#FF0000'; +const MODAL_WITNESS_RECT = { position: 'absolute', top: 340, left: 80, width: 80, height: 80 } as const; /** * The Modal each `secure-modal*` scenario shows: @@ -396,6 +403,7 @@ function SecureModalProbe({ nonce, setMoving, variant }: ProbeProps & { variant: style={{ ...MODAL_SHEET_RECT, backgroundColor: SECURE_MODAL_SHEET_COLOUR }} /> + From 53023ab2ac9af5950aa93716e39651767b98cc41 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Mon, 5 Oct 2026 13:19:35 +0500 Subject: [PATCH 11/19] fix(privacy): drop a JS runtime's Modal surfaces when it goes away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-review 2 fix (N7). After a JS reload with a secure Modal open, the old runtime's Modal lane failed closed for the rest of the process: dispose forgot its root and nothing retried it, so every recording was black. A Modal surface's key is a React tag only its runtime knows, so the store now drops every surface but the main one when a runtime goes: the new module claims the store on construction (Android constructor, iOS init) and the old one releases it on invalidate. Claim and release are serialised, and a release by a module whose claim is no longer current drops nothing, so an old module invalidated after the new one started cannot drop the new runtime's surfaces. A disposed Android tracker keeps nothing pending. The main lane keeps the N4 behaviour. N8 (ruled): the store headers now say the main surface stays one whole-display rectangle while no React root can be found. Tests: JVM claimingANewRuntimeDropsEveryModalSurfaceButKeepsTheMainOne, releasingTheCurrentRuntimeDropsItsModalSurfaces, releasingAnOldRuntimeLeavesTheNewRuntimesSurfaces, disposingWithAnOpenModalLeavesNothingPending, and the three XCTest twins. Mutations, each reverted: claim without dropping (2 failures), release ignoring the claim (1), a disposed tracker re-pending (1). ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --- .../com/bugsee/reactnative/BugseeModule.java | 10 ++++ .../reactnative/ReactRootOriginTracker.java | 14 +++++ .../reactnative/SecureRectangleStore.java | 53 ++++++++++++++++++- .../ReactRootOriginTrackerTest.java | 26 +++++++++ .../reactnative/SecureRectangleStoreTest.java | 50 +++++++++++++++++ packages/react-native/ios/BugseeModule.mm | 8 +++ .../BugseeRNSupport/BGSRNSecureRectangles.m | 34 ++++++++++++ .../include/BGSRNSecureRectangles.h | 18 ++++++- .../BGSRNSecureRectanglesTests.m | 42 +++++++++++++++ 9 files changed, 253 insertions(+), 2 deletions(-) diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java index a9edd3cd..0015faa4 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java @@ -109,11 +109,18 @@ public void logUnhandledException( */ private final DataRequestBridge.Sink dataRequestSink = this::emitRequest; + /** This module's claim on the secure store (SecureRectangleStore#claimRuntime). */ + private final int secureRuntime; + /** Spans this module is holding. {@link #invalidate()} drops them. */ private final SpanHandles spanHandles = new SpanHandles(); public BugseeModule(final ReactApplicationContext context) { super(context); + // A new JS runtime: Modal surfaces the previous one left behind (a + // reload with a secure Modal open) are dropped before this one's JS + // can publish. + secureRuntime = SecureRectangleStore.shared().claimRuntime(); originTracker = new ReactRootOriginTracker(context, SecureRectangleStore.shared()); // refreshSoon only posts to the UI thread, which is all the SDK's pull // thread may do. @@ -169,6 +176,9 @@ public void invalidate() { spanHandles.releaseAll(); SecureRectanglePulls.shared().clearRefresher(pullRefresher); originTracker.dispose(); + // This runtime's Modal surfaces cannot be cleared by its JS any more. + // A no-op when the next module has already claimed the store. + SecureRectangleStore.shared().releaseRuntime(secureRuntime); super.invalidate(); } diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/ReactRootOriginTracker.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/ReactRootOriginTracker.java index fb97098c..1de69b54 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/ReactRootOriginTracker.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/ReactRootOriginTracker.java @@ -364,6 +364,11 @@ private void forget(@NonNull final WatchedRoot entry) { entry.token.release(); final int surfaceKey = entry.handle.surfaceKey(); surfaceOrigins.remove(surfaceKey); + if (disposed) { + // The runtime is gone: its Modal surfaces are the module's to + // drop (SecureRectangleStore.releaseRuntime), not to retry. + return; + } if (store.hasRectangles(surfaceKey)) { store.forgetOrigin(surfaceKey); pendingSurfaces.add(surfaceKey); @@ -460,6 +465,15 @@ void detach() { } watched.clear(); surfaceOrigins.clear(); + if (disposed) { + pendingSurfaces.clear(); + } + } + + /** How many surfaces wait for their dialog root. Tests read it. */ + @UiThread + int pendingSurfaceCount() { + return pendingSurfaces.size(); } @UiThread diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/SecureRectangleStore.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/SecureRectangleStore.java index 2fdacbad..4b0047a4 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/SecureRectangleStore.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/SecureRectangleStore.java @@ -48,7 +48,15 @@ * rectangle covering the whole display: an unknown origin must not place a * secure region anywhere it could miss. That includes the main surface, so * the very first publish masks the whole display until the tracker's first - * read, a fraction of a second. + * read, a fraction of a second. While no React root can be found at all, + * the main surface's rectangles stay one whole-display rectangle: there is + * no origin to read, and the store will not guess one (ruled, N8). + * + *

A {@code } surface belongs to one JS runtime: its key is a React + * tag that the next runtime does not know. A reload drops every surface but + * the main one ({@link #claimRuntime}, {@link #releaseRuntime}), so a Modal + * that was open when the old runtime went away does not mask the display + * for the rest of the process. * *

Every served rectangle is clamped to the display's real size once it * is known ({@link #setDisplayBounds}). The SDK's native video mask clamps a @@ -348,6 +356,49 @@ void setDisplayBounds(final int display, final int width, final int height) { : previous.withBounds(new int[] { width, height })); } + /** The current JS runtime's claim; see {@link #claimRuntime}. */ + private int runtime; + + /** + * A new JS runtime's module is starting: drops every surface but the main + * one, whose keys the new runtime cannot know, and returns its claim. + * Synchronised with {@link #releaseRuntime} so an old module released + * after the new one started cannot drop the new runtime's surfaces. + */ + synchronized int claimRuntime() { + runtime++; + dropNonMainSurfaces(); + return runtime; + } + + /** + * The module holding {@code claim} is gone. Drops every surface but the + * main one, unless a newer runtime has already claimed the store. + */ + synchronized void releaseRuntime(final int claim) { + if (claim == runtime) { + dropNonMainSurfaces(); + } + } + + /** Drops every surface but the main one, on every display. */ + void dropNonMainSurfaces() { + for (final Integer display : byDisplay.keySet()) { + update(display, previous -> { + if (previous.lanes.isEmpty() + || (previous.lanes.size() == 1 && previous.lanes.containsKey(MAIN_SURFACE))) { + return previous; + } + final TreeMap nextLanes = new TreeMap<>(); + final Lane main = previous.lanes.get(MAIN_SURFACE); + if (main != null) { + nextLanes.put(MAIN_SURFACE, main); + } + return previous.withLanes(nextLanes); + }); + } + } + /** Whether {@code display}'s real size has been recorded. */ boolean hasDisplayBounds(final int display) { final Snapshot current = byDisplay.get(display); diff --git a/packages/react-native/android/src/test/java/com/bugsee/reactnative/ReactRootOriginTrackerTest.java b/packages/react-native/android/src/test/java/com/bugsee/reactnative/ReactRootOriginTrackerTest.java index bead2d06..07ba6f6a 100644 --- a/packages/react-native/android/src/test/java/com/bugsee/reactnative/ReactRootOriginTrackerTest.java +++ b/packages/react-native/android/src/test/java/com/bugsee/reactnative/ReactRootOriginTrackerTest.java @@ -735,6 +735,32 @@ public void theRootsDisplaySizeBoundsAFailClosedSurface() { assertArrayEquals(new int[] { 0, 0, 720, 1612 }, served(store)); } + /** N7: a disposed tracker (the old runtime) keeps nothing pending for a Modal it watched. */ + @Test + public void disposingWithAnOpenModalLeavesNothingPending() { + final FakeResolver resolver = new FakeResolver(); + resolver.answer = dialog(56, 0, 0); + final SecureRectangleStore store = new SecureRectangleStore(); + final ReactRootOriginTracker tracker = new ReactRootOriginTracker( + new FakeLifecycleSource(), new QueueRootFinder(), resolver, store); + store.set(0, 56, new int[] { 208, 560, 608, 760 }); + tracker.watchSurfaceNow(56); + // A second Modal whose root was never found: pending. + resolver.answer = null; + store.set(0, 57, new int[] { 1, 2, 3, 4 }); + tracker.watchSurfaceNow(57); + assertEquals(1, tracker.pendingSurfaceCount()); + + tracker.dispose(); + // dispose() posts detach() to the UI thread; run it as that would. + tracker.detach(); + tracker.refresh(); + + assertEquals(0, tracker.pendingSurfaceCount()); + // Nothing is resolved after dispose. + assertEquals(2, resolver.calls); + } + @Test public void constructorRegistersAsALifecycleListener() { final FakeLifecycleSource lifecycle = new FakeLifecycleSource(); diff --git a/packages/react-native/android/src/test/java/com/bugsee/reactnative/SecureRectangleStoreTest.java b/packages/react-native/android/src/test/java/com/bugsee/reactnative/SecureRectangleStoreTest.java index 548ecabf..20359e8a 100644 --- a/packages/react-native/android/src/test/java/com/bugsee/reactnative/SecureRectangleStoreTest.java +++ b/packages/react-native/android/src/test/java/com/bugsee/reactnative/SecureRectangleStoreTest.java @@ -472,6 +472,56 @@ public void hasRectanglesIsTrueOnlyForASurfaceThatHoldsSome() { assertFalse(store.hasRectangles(99)); } + // ---- A JS reload (N7) ------------------------------------------------ + + /** A new runtime drops the Modal surfaces the old one left; the main one stays. */ + @Test + public void claimingANewRuntimeDropsEveryModalSurfaceButKeepsTheMainOne() { + final SecureRectangleStore store = located(); + store.setDisplayBounds(DISPLAY, WIDTH, HEIGHT); + store.set(DISPLAY, new int[] { 10, 20, 30, 40 }); + store.set(DISPLAY, MODAL_SURFACE, new int[] { 100, 200, 150, 250 }); + store.set(1, 43, new int[] { 1, 2, 3, 4 }); + assertArrayEquals(new int[] { 10, 20, 30, 40, 0, 0, WIDTH, HEIGHT }, rectanglesOf(store.snapshot(DISPLAY))); + final int before = store.snapshot(DISPLAY)[0]; + + store.claimRuntime(); + + assertFalse(store.hasSurface(MODAL_SURFACE)); + assertFalse(store.hasSurface(43)); + assertTrue(store.hasSurface(MAIN_SURFACE)); + final int[] packed = store.snapshot(DISPLAY); + assertNotEquals(before, packed[0]); + assertArrayEquals(new int[] { 10, 20, 30, 40 }, rectanglesOf(packed)); + assertEquals(0, store.snapshot(1)[1]); + } + + @Test + public void releasingTheCurrentRuntimeDropsItsModalSurfaces() { + final SecureRectangleStore store = located(); + final int claim = store.claimRuntime(); + store.set(DISPLAY, MODAL_SURFACE, new int[] { 100, 200, 150, 250 }); + + store.releaseRuntime(claim); + + assertFalse(store.hasSurface(MODAL_SURFACE)); + } + + /** A reload can start the new module before the old one is released. */ + @Test + public void releasingAnOldRuntimeLeavesTheNewRuntimesSurfaces() { + final SecureRectangleStore store = located(); + final int old = store.claimRuntime(); + store.set(DISPLAY, MODAL_SURFACE, new int[] { 100, 200, 150, 250 }); + store.claimRuntime(); + store.set(DISPLAY, 58, new int[] { 1, 2, 3, 4 }); + + store.releaseRuntime(old); + + assertTrue(store.hasSurface(58)); + assertFalse(store.hasSurface(MODAL_SURFACE)); + } + @Test public void anEmptySurfaceWithNoOriginServesNothing() { final SecureRectangleStore store = new SecureRectangleStore(); diff --git a/packages/react-native/ios/BugseeModule.mm b/packages/react-native/ios/BugseeModule.mm index bc408fa3..057b0a33 100644 --- a/packages/react-native/ios/BugseeModule.mm +++ b/packages/react-native/ios/BugseeModule.mm @@ -910,6 +910,8 @@ @interface BugseeModule () @implementation BugseeModule { os_unfair_lock _spanRegistryLock; + /// This module's claim on the secure store (`claimRuntime`). + NSInteger _secureRuntime; BOOL _spansRetired; } @@ -925,6 +927,9 @@ @implementation BugseeModule { - (instancetype)init { if ((self = [super init])) { _spanRegistryLock = OS_UNFAIR_LOCK_INIT; + // A new JS runtime: Modal surfaces the previous one left behind (a reload + // with a secure Modal open) are dropped before this one's JS can publish. + _secureRuntime = [BGSRNSecureRectangles.shared claimRuntime]; BGSRNSetWrapper((id)[BGSRNWrapper wrapperWithoutJsRuntime], YES); BGSRNInstallConsoleCapture(); BGSRNInstallLogEventFilter(); @@ -1018,6 +1023,9 @@ - (void)invalidate { // No super call: `invalidate` comes from RCTInvalidating, and // NativeBugseeSpecBase inherits NSObject, which does not declare it. [BGSRNEventBus.shared detach:self]; + // This runtime's Modal surfaces cannot be cleared by its JS any more. A + // no-op when the next module has already claimed the store. + [BGSRNSecureRectangles.shared releaseRuntime:_secureRuntime]; // Also completes every report handle this module's JS was given: the next // runtime cannot know them, so the reports must not wait out their // deadlines. diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m index 05f16baa..f0e8ef6b 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m @@ -92,6 +92,8 @@ @implementation BGSRNSecureRectangles { /// rather than a queue: the pull happens on main once per captured frame and /// must not be made to hop. NSLock *_lock; + /// The current JS runtime's claim; see `claimRuntime`. Under `_lock`. + NSInteger _runtime; } + (BGSRNSecureRectangles *)shared { @@ -189,6 +191,38 @@ - (void)setDisplaySize:(CGSize)size forDisplay:(NSInteger)display { [_lock unlock]; } +- (NSInteger)claimRuntime { + [_lock lock]; + _runtime += 1; + const NSInteger claim = _runtime; + [self dropNonMainSurfacesLocked]; + [_lock unlock]; + return claim; +} + +- (void)releaseRuntime:(NSInteger)claim { + [_lock lock]; + if (claim == _runtime) { + [self dropNonMainSurfacesLocked]; + } + [_lock unlock]; +} + +/// Drops every surface but the main one, moving each display's version when +/// what it serves changes. Caller holds `_lock`. +- (void)dropNonMainSurfacesLocked { + for (NSNumber *display in _lanesByDisplay.allKeys) { + NSMutableDictionary *lanes = _lanesByDisplay[display]; + NSData *previousServed = [self servedLockedForDisplay:display.integerValue]; + for (NSNumber *surface in lanes.allKeys) { + if (surface.integerValue != BGSRNSecureMainSurface) { + [lanes removeObjectForKey:surface]; + } + } + [self moveVersionLockedForDisplay:display.integerValue ifServedDiffersFrom:previousServed]; + } +} + - (NSArray *)surfacesForDisplay:(NSInteger)display { [_lock lock]; NSMutableArray *surfaces = [NSMutableArray array]; 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 5d6e7b49..7778f61a 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSecureRectangles.h +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSecureRectangles.h @@ -45,7 +45,14 @@ FOUNDATION_EXPORT const int32_t BGSRNSecureFallbackDisplaySize; /// /// Fails closed: a surface whose origin has not been recorded yet, the main /// one included, serves one rectangle covering its display (the screen's -/// bounds) instead of its rectangles. +/// bounds) instead of its rectangles. While no React root can be found at +/// all, the main surface stays that one rectangle: there is no origin to +/// record, and the store will not guess one (ruled, N8). +/// +/// A Modal surface belongs to one JS runtime: its key is a React tag the next +/// runtime does not know. A reload drops every surface but the main one +/// (`claimRuntime`, `releaseRuntime:`), so a Modal that was open when the old +/// runtime went away does not mask the screen for the rest of the process. /// /// ## Threading /// @@ -96,6 +103,15 @@ FOUNDATION_EXPORT const int32_t BGSRNSecureFallbackDisplaySize; /// order: the ones whose origin the pull refreshes. - (NSArray *)surfacesForDisplay:(NSInteger)display; +/// A new JS runtime's module is starting: drops every surface but the main +/// one and returns its claim. Serialised with `releaseRuntime:`, so an old +/// module released after the new one started cannot drop its surfaces. +- (NSInteger)claimRuntime; + +/// The module holding `claim` is gone: drops every surface but the main one, +/// unless a newer runtime has already claimed the store. +- (void)releaseRuntime:(NSInteger)claim; + /// Forgets `surface` on every display where it holds no rectangles (its /// Modal is gone). A surface that still holds rectangles stays. - (void)dropSurfaceIfEmpty:(NSInteger)surface; diff --git a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m index a14213f1..c14d14c4 100644 --- a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m +++ b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m @@ -325,6 +325,48 @@ - (void)testDropsOnlyAnEmptyNonMainSurface { XCTAssertEqualObjects([self unpack:[_store snapshotForDisplay:0]][0], before); } +/// A new runtime drops the Modal surfaces the old one left; the main one stays. +- (void)testClaimingANewRuntimeDropsEveryModalSurfaceButKeepsTheMainOne { + const int32_t mainRects[] = {10, 20, 30, 40}; + const int32_t modalRects[] = {100, 200, 150, 250}; + [_store setDisplaySize:CGSizeMake(402, 874) forDisplay:0]; + [_store setCoordinates:mainRects count:4 forDisplay:0]; + [_store setCoordinates:modalRects count:4 forDisplay:0 surface:42]; + [_store setCoordinates:modalRects count:4 forDisplay:1 surface:43]; + NSNumber *before = [self unpack:[_store snapshotForDisplay:0]][0]; + + [_store claimRuntime]; + + XCTAssertEqualObjects([_store surfacesForDisplay:0], @[]); + XCTAssertEqualObjects([_store surfacesForDisplay:1], @[]); + NSArray *packed = [self unpack:[_store snapshotForDisplay:0]]; + XCTAssertNotEqualObjects(packed[0], before); + XCTAssertEqualObjects([packed subarrayWithRange:NSMakeRange(1, 5)], (@[ @1, @10, @20, @30, @40 ])); +} + +- (void)testReleasingTheCurrentRuntimeDropsItsModalSurfaces { + const int32_t rects[] = {1, 2, 3, 4}; + const NSInteger claim = [_store claimRuntime]; + [_store setCoordinates:rects count:4 forDisplay:0 surface:42]; + + [_store releaseRuntime:claim]; + + XCTAssertEqualObjects([_store surfacesForDisplay:0], @[]); +} + +/// A reload can start the new module before the old one is released. +- (void)testReleasingAnOldRuntimeLeavesTheNewRuntimesSurfaces { + const int32_t rects[] = {1, 2, 3, 4}; + const NSInteger old = [_store claimRuntime]; + [_store setCoordinates:rects count:4 forDisplay:0 surface:42]; + [_store claimRuntime]; + [_store setCoordinates:rects count:4 forDisplay:0 surface:58]; + + [_store releaseRuntime:old]; + + XCTAssertEqualObjects([_store surfacesForDisplay:0], @[ @58 ]); +} + /// The shared store outlives any one wrapper: the init provider registers one /// before launch and setWrapperInfo swaps in another, and the regions the app /// marked secret must survive that. From be4bf5e948ed3557315ff3f50f10958c6977cdb2 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Mon, 5 Oct 2026 13:26:32 +0500 Subject: [PATCH 12/19] chore(android): log at debug what a JS runtime's claim and release drop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two debug-level lines (BugseeRN, numbers only): how many Modal surfaces with rectangles a new runtime's claim found left behind, and how many the released runtime held and left. They are the device evidence for N7: a dev reload on the WOD_LX1 logs `released: 0 ... held, 0 left` then `claimed: dropped 0`, since RN's teardown has already cleared the Modal. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --- .../java/com/bugsee/reactnative/BugseeModule.java | 10 ++++++++++ .../bugsee/reactnative/SecureRectangleStore.java | 13 +++++++++++++ .../reactnative/SecureRectangleStoreTest.java | 3 +++ 3 files changed, 26 insertions(+) diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java index 0015faa4..39516d84 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java @@ -120,7 +120,12 @@ public BugseeModule(final ReactApplicationContext context) { // A new JS runtime: Modal surfaces the previous one left behind (a // reload with a secure Modal open) are dropped before this one's JS // can publish. + final int leftBehind = SecureRectangleStore.shared().nonMainSurfacesWithRectangles(); secureRuntime = SecureRectangleStore.shared().claimRuntime(); + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "secure runtime claimed: dropped " + leftBehind + + " Modal surface(s) the previous runtime left"); + } originTracker = new ReactRootOriginTracker(context, SecureRectangleStore.shared()); // refreshSoon only posts to the UI thread, which is all the SDK's pull // thread may do. @@ -178,7 +183,12 @@ public void invalidate() { originTracker.dispose(); // This runtime's Modal surfaces cannot be cleared by its JS any more. // A no-op when the next module has already claimed the store. + final int open = SecureRectangleStore.shared().nonMainSurfacesWithRectangles(); SecureRectangleStore.shared().releaseRuntime(secureRuntime); + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "secure runtime released: " + open + " Modal surface(s) held, " + + SecureRectangleStore.shared().nonMainSurfacesWithRectangles() + " left"); + } super.invalidate(); } diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/SecureRectangleStore.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/SecureRectangleStore.java index 4b0047a4..9e307eaf 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/SecureRectangleStore.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/SecureRectangleStore.java @@ -381,6 +381,19 @@ synchronized void releaseRuntime(final int claim) { } } + /** How many surfaces other than the main one hold rectangles, on every display. */ + int nonMainSurfacesWithRectangles() { + int count = 0; + for (final Snapshot snapshot : byDisplay.values()) { + for (final java.util.Map.Entry entry : snapshot.lanes.entrySet()) { + if (entry.getKey() != MAIN_SURFACE && entry.getValue().raw.length > 0) { + count++; + } + } + } + return count; + } + /** Drops every surface but the main one, on every display. */ void dropNonMainSurfaces() { for (final Integer display : byDisplay.keySet()) { diff --git a/packages/react-native/android/src/test/java/com/bugsee/reactnative/SecureRectangleStoreTest.java b/packages/react-native/android/src/test/java/com/bugsee/reactnative/SecureRectangleStoreTest.java index 20359e8a..46b6cc49 100644 --- a/packages/react-native/android/src/test/java/com/bugsee/reactnative/SecureRectangleStoreTest.java +++ b/packages/react-native/android/src/test/java/com/bugsee/reactnative/SecureRectangleStoreTest.java @@ -484,9 +484,12 @@ public void claimingANewRuntimeDropsEveryModalSurfaceButKeepsTheMainOne() { store.set(1, 43, new int[] { 1, 2, 3, 4 }); assertArrayEquals(new int[] { 10, 20, 30, 40, 0, 0, WIDTH, HEIGHT }, rectanglesOf(store.snapshot(DISPLAY))); final int before = store.snapshot(DISPLAY)[0]; + assertEquals(2, store.nonMainSurfacesWithRectangles()); store.claimRuntime(); + assertEquals(0, store.nonMainSurfacesWithRectangles()); + assertFalse(store.hasSurface(MODAL_SURFACE)); assertFalse(store.hasSurface(43)); assertTrue(store.hasSurface(MAIN_SURFACE)); From 756d53184bb49df191ddc34adbb4d6cd239ee8e4 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Mon, 5 Oct 2026 16:18:24 +0500 Subject: [PATCH 13/19] fix(privacy,android): ignore secure-store writes from a runtime whose claim is stale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reload can construct the new BugseeModule before the old one is invalidated, and Fabric numbers React tags from 1 again. The old module's late publish could put back a Modal lane the new claim dropped, or its unmount's empty publish could clear the new runtime's lane on the same tag and uncover the new Modal. Every lane write a runtime makes now carries its claim: the module's publish (publishForRuntime) and its tracker's setOrigin, forgetOrigin and dropSurfaceIfEmpty. The store ignores a write whose claim is not current, under the same lock as claimRuntime/releaseRuntime, and logs it at debug with the two claim numbers only. Addresses PR 48 review thread 4182289966. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --- .../com/bugsee/reactnative/BugseeModule.java | 7 +- .../reactnative/ReactRootOriginTracker.java | 31 +++++-- .../reactnative/SecureRectangleStore.java | 86 ++++++++++++++++++- .../ReactRootOriginTrackerTest.java | 27 ++++++ .../reactnative/SecureRectangleStoreTest.java | 78 +++++++++++++++++ 5 files changed, 218 insertions(+), 11 deletions(-) diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java index 39516d84..1a789e04 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/BugseeModule.java @@ -126,7 +126,7 @@ public BugseeModule(final ReactApplicationContext context) { Log.d(TAG, "secure runtime claimed: dropped " + leftBehind + " Modal surface(s) the previous runtime left"); } - originTracker = new ReactRootOriginTracker(context, SecureRectangleStore.shared()); + originTracker = new ReactRootOriginTracker(context, SecureRectangleStore.shared(), secureRuntime); // refreshSoon only posts to the UI thread, which is all the SDK's pull // thread may do. pullRefresher = originTracker::refreshSoon; @@ -303,7 +303,10 @@ private void publishSecure(final double display, final int surface, final Readab // the store serves them moved by that surface's display origin, // which the tracker keeps current. Re-read now too, in case the // window moved without a layout pass. - if (!SecureRectangleStore.shared().publishOrLog((int) display, surface, flat)) { + // Ignored once a newer runtime has claimed the store: this + // module's late writes (a reload's teardown) must not touch + // the next runtime's lanes, whose keys may be the same tags. + if (!SecureRectangleStore.shared().publishForRuntime(secureRuntime, (int) display, surface, flat)) { return; } originTracker.refreshSoon(); diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/ReactRootOriginTracker.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/ReactRootOriginTracker.java index 1de69b54..94750636 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/ReactRootOriginTracker.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/ReactRootOriginTracker.java @@ -74,6 +74,12 @@ final class ReactRootOriginTracker implements LifecycleEventListener { private final RootFinder rootFinder; private final SecureRectangleStore store; + /** + * The owning module's claim on {@link #store}: every lane write carries + * it, so a tracker whose runtime has been replaced writes nothing. + */ + private final int claim; + /** UI thread only: the activity React root, when found. */ @Nullable private RootHandle root; @@ -144,9 +150,9 @@ private static final class WatchedRoot { private volatile boolean disposed; ReactRootOriginTracker(@NonNull final ReactApplicationContext context, - @NonNull final SecureRectangleStore store) { + @NonNull final SecureRectangleStore store, final int claim) { this(new ContextLifecycleSource(context), new ActivityRootFinder(context), - new ModalSurfaceResolver(context), store); + new ModalSurfaceResolver(context), store, claim); } /** @@ -163,14 +169,25 @@ private static final class WatchedRoot { this(lifecycle, rootFinder, surfaceKey -> null, store); } - /** Test seam, as above, with a fake {@link SurfaceResolver} too. */ + /** + * Test seam, as above, with a fake {@link SurfaceResolver} too. Writes + * with whatever claim {@code store} holds now. + */ ReactRootOriginTracker(@NonNull final LifecycleSource lifecycle, @NonNull final RootFinder rootFinder, @NonNull final SurfaceResolver surfaceResolver, @NonNull final SecureRectangleStore store) { + this(lifecycle, rootFinder, surfaceResolver, store, store.currentClaim()); + } + + /** Test seam, as above, writing with {@code claim}. */ + ReactRootOriginTracker(@NonNull final LifecycleSource lifecycle, + @NonNull final RootFinder rootFinder, @NonNull final SurfaceResolver surfaceResolver, + @NonNull final SecureRectangleStore store, final int claim) { this.lifecycle = lifecycle; this.rootFinder = rootFinder; this.surfaceResolver = surfaceResolver; this.store = store; + this.claim = claim; lifecycle.addLifecycleEventListener(this); refreshSoon(); } @@ -332,7 +349,7 @@ private void resolvePending() { for (final Integer surfaceKey : new ArrayList<>(pendingSurfaces)) { if (!store.hasRectangles(surfaceKey)) { pendingSurfaces.remove(surfaceKey); - store.dropSurfaceIfEmpty(surfaceKey); + store.dropSurfaceIfEmptyForRuntime(claim, surfaceKey); continue; } watchSurfaceNow(surfaceKey); @@ -370,10 +387,10 @@ private void forget(@NonNull final WatchedRoot entry) { return; } if (store.hasRectangles(surfaceKey)) { - store.forgetOrigin(surfaceKey); + store.forgetOriginForRuntime(claim, surfaceKey); pendingSurfaces.add(surfaceKey); } else { - store.dropSurfaceIfEmpty(surfaceKey); + store.dropSurfaceIfEmptyForRuntime(claim, surfaceKey); } } @@ -418,7 +435,7 @@ private void publishOrigin(@NonNull final RootHandle handle, final boolean prima // The activity root is the main surface whatever its own view id: // JS publishes everything outside a there. final int surface = primary ? SecureRectangleStore.MAIN_SURFACE : handle.surfaceKey(); - store.setOrigin(snapshot.displayId, surface, origin[0], origin[1]); + store.setOriginForRuntime(claim, snapshot.displayId, surface, origin[0], origin[1]); if (primary) { lastOrigin = origin; } else { diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/SecureRectangleStore.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/SecureRectangleStore.java index 9e307eaf..e1d8b2d8 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/SecureRectangleStore.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/SecureRectangleStore.java @@ -58,6 +58,17 @@ * that was open when the old runtime went away does not mask the display * for the rest of the process. * + *

The claim also gates every lane write a runtime makes: the module's + * publish ({@link #publishForRuntime}) and its tracker's origin writes + * ({@link #setOriginForRuntime}, {@link #forgetOriginForRuntime}, {@link + * #dropSurfaceIfEmptyForRuntime}). A write whose claim is no longer current + * is ignored, under the same lock as the claim. A reload can construct the + * new module before the old one is invalidated, and Fabric numbers React + * tags from 1 again, so the old runtime's late writes would otherwise put + * back a lane the claim dropped or clear the new runtime's lane on the same + * key, uncovering its Modal. The display size is not a runtime's state: it + * is recorded by whoever reads it. + * *

Every served rectangle is clamped to the display's real size once it * is known ({@link #setDisplayBounds}). The SDK's native video mask clamps a * rectangle's rows to the frame but not its right edge to a row, so a @@ -300,6 +311,21 @@ boolean publishOrLog(final int display, @Nullable final int[] coordinates) { } boolean publishOrLog(final int display, final int surface, @Nullable final int[] coordinates) { + return publishForRuntime(currentClaim(), display, surface, coordinates); + } + + /** + * {@link #publishOrLog}, written by the module holding {@code claim}: ignored + * when a newer runtime has claimed the store since. What the TurboModule + * calls. + * + * @return whether {@code coordinates} was published + */ + synchronized boolean publishForRuntime(final int claim, final int display, final int surface, + @Nullable final int[] coordinates) { + if (!isCurrentLocked(claim)) { + return false; + } try { set(display, surface, coordinates); return true; @@ -342,6 +368,18 @@ void setOrigin(final int display, final int surface, final int originX, final in lane -> lane.withOrigin(originX, originY))); } + /** + * {@link #setOrigin(int, int, int, int)}, written by the module holding + * {@code claim}: ignored when a newer runtime has claimed the store since. + * An old runtime's dialog root must not place the new runtime's Modal. + */ + synchronized void setOriginForRuntime(final int claim, final int display, final int surface, + final int originX, final int originY) { + if (isCurrentLocked(claim)) { + setOrigin(display, surface, originX, originY); + } + } + /** * Records {@code display}'s real size in pixels: what a fail-closed lane * serves, and what every rectangle is clamped to. @@ -356,9 +394,33 @@ void setDisplayBounds(final int display, final int width, final int height) { : previous.withBounds(new int[] { width, height })); } - /** The current JS runtime's claim; see {@link #claimRuntime}. */ + /** The current JS runtime's claim; see {@link #claimRuntime}. Guarded by {@code this}. */ private int runtime; + /** + * The claim a runtime holds now. For writers that belong to no runtime + * of their own (tests, single-runtime callers); a module writes with the + * claim {@link #claimRuntime} gave it. + */ + synchronized int currentClaim() { + return runtime; + } + + /** + * Whether {@code claim} is still the current runtime's. A stale one is + * logged at debug, numbers only. Caller holds {@code this}. + */ + private boolean isCurrentLocked(final int claim) { + if (claim == runtime) { + return true; + } + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "secure write ignored: runtime claim " + claim + + " is stale, current " + runtime); + } + return false; + } + /** * A new JS runtime's module is starting: drops every surface but the main * one, whose keys the new runtime cannot know, and returns its claim. @@ -395,7 +457,7 @@ int nonMainSurfacesWithRectangles() { } /** Drops every surface but the main one, on every display. */ - void dropNonMainSurfaces() { + private void dropNonMainSurfaces() { for (final Integer display : byDisplay.keySet()) { update(display, previous -> { if (previous.lanes.isEmpty() @@ -412,6 +474,26 @@ void dropNonMainSurfaces() { } } + /** + * {@link #forgetOrigin}, by the module holding {@code claim}: ignored when + * a newer runtime has claimed the store since. + */ + synchronized void forgetOriginForRuntime(final int claim, final int surface) { + if (isCurrentLocked(claim)) { + forgetOrigin(surface); + } + } + + /** + * {@link #dropSurfaceIfEmpty}, by the module holding {@code claim}: ignored + * when a newer runtime has claimed the store since. + */ + synchronized void dropSurfaceIfEmptyForRuntime(final int claim, final int surface) { + if (isCurrentLocked(claim)) { + dropSurfaceIfEmpty(surface); + } + } + /** Whether {@code display}'s real size has been recorded. */ boolean hasDisplayBounds(final int display) { final Snapshot current = byDisplay.get(display); diff --git a/packages/react-native/android/src/test/java/com/bugsee/reactnative/ReactRootOriginTrackerTest.java b/packages/react-native/android/src/test/java/com/bugsee/reactnative/ReactRootOriginTrackerTest.java index 07ba6f6a..72552c4f 100644 --- a/packages/react-native/android/src/test/java/com/bugsee/reactnative/ReactRootOriginTrackerTest.java +++ b/packages/react-native/android/src/test/java/com/bugsee/reactnative/ReactRootOriginTrackerTest.java @@ -508,6 +508,33 @@ public void aSurfaceWhoseDialogRootIsNotFoundYetFailsClosedUntilARefreshFindsIt( assertArrayEquals(new int[] { 0, 0 }, tracker.cachedSurfaceOrigin(56)); } + /** + * A tracker whose module has been replaced (a reload, before the old + * module is invalidated) must not place the new runtime's Modal with its + * own, old dialog root: the key may be the same React tag. + */ + @Test + public void aTrackerWithAStaleClaimWritesNothingToTheNewRuntimesLane() { + final FakeResolver resolver = new FakeResolver(); + resolver.answer = dialog(56, 0, 0); + final SecureRectangleStore store = new SecureRectangleStore(); + store.setDisplayBounds(0, 1080, 2340); + final int old = store.claimRuntime(); + final ReactRootOriginTracker tracker = new ReactRootOriginTracker( + new FakeLifecycleSource(), new QueueRootFinder(), resolver, store, old); + final int current = store.claimRuntime(); + store.publishForRuntime(current, 0, 56, new int[] { 208, 560, 608, 760 }); + store.setOriginForRuntime(current, 0, 56, 100, 200); + final int[] before = store.snapshot(0); + + tracker.watchSurfaceNow(56); + tracker.refresh(); + + assertArrayEquals(before, store.snapshot(0)); + assertArrayEquals(new int[] { 308, 760, 708, 960 }, + java.util.Arrays.copyOfRange(store.snapshot(0), 2, 6)); + } + @Test public void aWatchedSurfaceIsResolvedOnce() { final FakeResolver resolver = new FakeResolver(); diff --git a/packages/react-native/android/src/test/java/com/bugsee/reactnative/SecureRectangleStoreTest.java b/packages/react-native/android/src/test/java/com/bugsee/reactnative/SecureRectangleStoreTest.java index 46b6cc49..a6a235f4 100644 --- a/packages/react-native/android/src/test/java/com/bugsee/reactnative/SecureRectangleStoreTest.java +++ b/packages/react-native/android/src/test/java/com/bugsee/reactnative/SecureRectangleStoreTest.java @@ -525,6 +525,84 @@ public void releasingAnOldRuntimeLeavesTheNewRuntimesSurfaces() { assertFalse(store.hasSurface(MODAL_SURFACE)); } + // ---- Writes from a runtime that is no longer current (PR 48) --------- + + /** + * The old module is not invalidated yet when the new one claims: its late + * publish must not put back the Modal lane the claim dropped, which would + * fail closed over the whole display for the rest of the process. + */ + @Test + public void aStaleNonEmptyWriteDoesNotRecreateADroppedLane() { + final SecureRectangleStore store = located(); + store.setDisplayBounds(DISPLAY, WIDTH, HEIGHT); + final int old = store.claimRuntime(); + assertTrue(store.publishForRuntime(old, DISPLAY, MODAL_SURFACE, new int[] { 100, 200, 150, 250 })); + store.claimRuntime(); + assertFalse(store.hasSurface(MODAL_SURFACE)); + final int[] before = store.snapshot(DISPLAY); + + assertFalse(store.publishForRuntime(old, DISPLAY, MODAL_SURFACE, new int[] { 100, 200, 150, 250 })); + store.setOriginForRuntime(old, DISPLAY, MODAL_SURFACE, 7, 9); + + assertFalse(store.hasSurface(MODAL_SURFACE)); + assertArrayEquals(before, store.snapshot(DISPLAY)); + } + + /** + * Fabric numbers React tags from 1 again after a reload, so the old + * runtime's unmount clears the same key the new runtime's Modal now + * publishes on. That empty write must not uncover the new Modal. + */ + @Test + public void aStaleEmptyWriteDoesNotClearTheNewRuntimesLaneOnTheSameTag() { + final SecureRectangleStore store = located(); + store.setDisplayBounds(DISPLAY, WIDTH, HEIGHT); + final int old = store.claimRuntime(); + store.publishForRuntime(old, DISPLAY, MODAL_SURFACE, new int[] { 1, 2, 3, 4 }); + final int current = store.claimRuntime(); + store.publishForRuntime(current, DISPLAY, MODAL_SURFACE, new int[] { 100, 200, 150, 250 }); + store.setOriginForRuntime(current, DISPLAY, MODAL_SURFACE, 10, 20); + final int[] before = store.snapshot(DISPLAY); + assertArrayEquals(new int[] { 110, 220, 160, 270 }, rectanglesOf(before)); + + assertFalse(store.publishForRuntime(old, DISPLAY, MODAL_SURFACE, new int[0])); + store.forgetOriginForRuntime(old, MODAL_SURFACE); + store.dropSurfaceIfEmptyForRuntime(old, MODAL_SURFACE); + store.setOriginForRuntime(old, DISPLAY, MODAL_SURFACE, 500, 500); + + assertArrayEquals(before, store.snapshot(DISPLAY)); + } + + @Test + public void theCurrentRuntimesWritesStillApply() { + final SecureRectangleStore store = located(); + store.setDisplayBounds(DISPLAY, WIDTH, HEIGHT); + store.claimRuntime(); + final int current = store.claimRuntime(); + + assertTrue(store.publishForRuntime(current, DISPLAY, MODAL_SURFACE, new int[] { 100, 200, 150, 250 })); + assertArrayEquals(new int[] { 0, 0, WIDTH, HEIGHT }, rectanglesOf(store.snapshot(DISPLAY))); + store.setOriginForRuntime(current, DISPLAY, MODAL_SURFACE, 10, 20); + assertArrayEquals(new int[] { 110, 220, 160, 270 }, rectanglesOf(store.snapshot(DISPLAY))); + store.forgetOriginForRuntime(current, MODAL_SURFACE); + assertArrayEquals(new int[] { 0, 0, WIDTH, HEIGHT }, rectanglesOf(store.snapshot(DISPLAY))); + assertTrue(store.publishForRuntime(current, DISPLAY, MODAL_SURFACE, new int[0])); + assertEquals(0, store.snapshot(DISPLAY)[1]); + store.dropSurfaceIfEmptyForRuntime(current, MODAL_SURFACE); + assertFalse(store.hasSurface(MODAL_SURFACE)); + } + + /** A malformed write from the current runtime is still rejected, not applied. */ + @Test + public void theCurrentRuntimesMalformedWriteIsStillRejected() { + final SecureRectangleStore store = located(); + final int current = store.claimRuntime(); + + assertFalse(store.publishForRuntime(current, DISPLAY, MODAL_SURFACE, new int[] { 1, 2, 3 })); + assertFalse(store.hasSurface(MODAL_SURFACE)); + } + @Test public void anEmptySurfaceWithNoOriginServesNothing() { final SecureRectangleStore store = new SecureRectangleStore(); From 3cbce1b079d79eb1b56877f1791182f1c07307a8 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Mon, 5 Oct 2026 16:31:24 +0500 Subject: [PATCH 14/19] fix(privacy,ios): ignore secure-store writes from a runtime whose claim is stale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The iOS twin of the Android fix. The module's rectangle writes now carry its claim (setCoordinates:count:forDisplay:surface:runtime:). The store ignores one whose claim is no longer current, under the same lock as claimRuntime/releaseRuntime, and logs it at debug with the two claim numbers only. So the old runtime's late publish can neither put back a dropped Modal lane nor clear the new runtime's lane on the same React tag. Origins, display sizes and empty-lane drops come from the wrapper's pull, which reads the live view tree and belongs to no runtime, so they are not claim-gated. Addresses PR 48 review thread 4182289966. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --- packages/react-native/ios/BugseeModule.mm | 6 +- .../BugseeRNSupport/BGSRNSecureRectangles.m | 38 +++++++++++ .../include/BGSRNSecureRectangles.h | 29 ++++++++- .../BGSRNSecureRectanglesTests.m | 64 +++++++++++++++++++ 4 files changed, 135 insertions(+), 2 deletions(-) diff --git a/packages/react-native/ios/BugseeModule.mm b/packages/react-native/ios/BugseeModule.mm index 057b0a33..74567f91 100644 --- a/packages/react-native/ios/BugseeModule.mm +++ b/packages/react-native/ios/BugseeModule.mm @@ -1096,10 +1096,14 @@ - (void)setSecureRectanglesOnSurface:(double)display flat[i] = (int32_t)llround([coordinates[i] doubleValue]); } + // Ignored once a newer runtime has claimed the store: this module's late + // writes (a reload's teardown) must not touch the next runtime's lanes, + // whose keys may be the same React tags. [BGSRNSecureRectangles.shared setCoordinates:flat count:count forDisplay:(NSInteger)display - surface:(NSInteger)llround(surface)]; + surface:(NSInteger)llround(surface) + runtime:_secureRuntime]; free(flat); } diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m index f0e8ef6b..2cd1698e 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m @@ -1,5 +1,7 @@ #import "BGSRNSecureRectangles.h" +#import + /// Four coordinates per rectangle: left, top, right, bottom. static const NSUInteger kCoordinatesPerRectangle = 4; @@ -126,6 +128,36 @@ - (BOOL)setCoordinates:(const int32_t *)coordinates count:(NSUInteger)count forDisplay:(NSInteger)display surface:(NSInteger)surface { + return [self setCoordinates:coordinates + count:count + forDisplay:display + surface:surface + runtime:[self currentClaim]]; +} + +- (NSInteger)currentClaim { + [_lock lock]; + const NSInteger claim = _runtime; + [_lock unlock]; + return claim; +} + +/// Whether `claim` is still the current runtime's. A stale one is logged at +/// debug, numbers only. Caller holds `_lock`. +- (BOOL)isCurrentLocked:(NSInteger)claim { + if (claim == _runtime) { + return YES; + } + os_log_debug(OS_LOG_DEFAULT, "BugseeRN secure write ignored: runtime claim %ld is stale, current %ld", + (long)claim, (long)_runtime); + return NO; +} + +- (BOOL)setCoordinates:(const int32_t *)coordinates + count:(NSUInteger)count + forDisplay:(NSInteger)display + surface:(NSInteger)surface + runtime:(NSInteger)claim { if (count % kCoordinatesPerRectangle != 0) { return NO; } @@ -140,6 +172,12 @@ - (BOOL)setCoordinates:(const int32_t *)coordinates : [NSData dataWithBytes:coordinates length:count * sizeof(int32_t)]; [_lock lock]; + // Under the same lock as claimRuntime and releaseRuntime: a write checked + // here cannot land after a newer claim has dropped the lanes. + if (![self isCurrentLocked:claim]) { + [_lock unlock]; + return NO; + } [self changeLaneLockedForDisplay:display surface:surface change:^BGSRNSecureLane *(BGSRNSecureLane *prior) { 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 7778f61a..9c0f79bd 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSecureRectangles.h +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSecureRectangles.h @@ -54,6 +54,16 @@ FOUNDATION_EXPORT const int32_t BGSRNSecureFallbackDisplaySize; /// (`claimRuntime`, `releaseRuntime:`), so a Modal that was open when the old /// runtime went away does not mask the screen for the rest of the process. /// +/// The claim also gates the module's rectangle writes +/// (`setCoordinates:count:forDisplay:surface:runtime:`): one whose claim is no +/// longer current is ignored, under the same lock as the claim. A reload can +/// start the new module before the old one is invalidated, and Fabric numbers +/// React tags from 1 again, so the old runtime's late writes would otherwise +/// put back a lane the claim dropped, or clear the new runtime's lane on the +/// same key and uncover its Modal. Origins, display sizes and empty-lane drops +/// come from the wrapper's pull, which reads the live view tree and belongs to +/// no runtime. +/// /// ## Threading /// /// Writes arrive on the JS thread; the pull is on the main thread. Every @@ -81,12 +91,29 @@ FOUNDATION_EXPORT const int32_t BGSRNSecureFallbackDisplaySize; forDisplay:(NSInteger)display; /// Publishes `coordinates` for one surface on `display`. Other surfaces keep -/// their rectangles and origins. +/// their rectangles and origins. Writes as whichever runtime holds the claim +/// now; a module uses the `runtime:` variant with its own claim. - (BOOL)setCoordinates:(nullable const int32_t *)coordinates count:(NSUInteger)count forDisplay:(NSInteger)display surface:(NSInteger)surface; +/// `setCoordinates:count:forDisplay:surface:`, written by the module holding +/// `claim` (from `claimRuntime`). What the TurboModule calls. +/// +/// @return NO, publishing nothing, when `count` is not whole rectangles or a +/// newer runtime has claimed the store since `claim` was given. +- (BOOL)setCoordinates:(nullable const int32_t *)coordinates + count:(NSUInteger)count + forDisplay:(NSInteger)display + surface:(NSInteger)surface + runtime:(NSInteger)claim; + +/// The claim a runtime holds now. For writers that belong to no runtime of +/// their own (tests, single-runtime callers); a module writes with the claim +/// `claimRuntime` gave it. +- (NSInteger)currentClaim; + /// Records where the main surface's window sits on `display`'s screen, in /// points. Re-recording the same origin costs nothing. - (void)setOrigin:(CGPoint)origin forDisplay:(NSInteger)display; diff --git a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m index c14d14c4..8708c894 100644 --- a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m +++ b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m @@ -367,6 +367,70 @@ - (void)testReleasingAnOldRuntimeLeavesTheNewRuntimesSurfaces { XCTAssertEqualObjects([_store surfacesForDisplay:0], @[ @58 ]); } +/// The old module is not invalidated yet when the new one claims: its late +/// publish must not put back the Modal lane the claim dropped, which would +/// fail closed over the whole screen for the rest of the process. +- (void)testAStaleNonEmptyWriteDoesNotRecreateADroppedLane { + const int32_t rects[] = {100, 200, 150, 250}; + [_store setDisplaySize:CGSizeMake(402, 874) forDisplay:0]; + const NSInteger old = [_store claimRuntime]; + XCTAssertTrue([_store setCoordinates:rects count:4 forDisplay:0 surface:42 runtime:old]); + [_store claimRuntime]; + XCTAssertEqualObjects([_store surfacesForDisplay:0], @[]); + NSData *before = [_store snapshotForDisplay:0]; + + XCTAssertFalse([_store setCoordinates:rects count:4 forDisplay:0 surface:42 runtime:old]); + + XCTAssertEqualObjects([_store surfacesForDisplay:0], @[]); + XCTAssertEqualObjects([_store snapshotForDisplay:0], before); +} + +/// Fabric numbers React tags from 1 again after a reload, so the old +/// runtime's unmount clears the same key the new runtime's Modal now +/// publishes on. That empty write must not uncover the new Modal. +- (void)testAStaleEmptyWriteDoesNotClearTheNewRuntimesLaneOnTheSameTag { + const int32_t oldRects[] = {1, 2, 3, 4}; + const int32_t newRects[] = {100, 200, 150, 250}; + [_store setDisplaySize:CGSizeMake(402, 874) forDisplay:0]; + const NSInteger old = [_store claimRuntime]; + [_store setCoordinates:oldRects count:4 forDisplay:0 surface:42 runtime:old]; + const NSInteger current = [_store claimRuntime]; + [_store setCoordinates:newRects count:4 forDisplay:0 surface:42 runtime:current]; + [_store setOrigin:CGPointMake(10, 20) forDisplay:0 surface:42]; + NSData *before = [_store snapshotForDisplay:0]; + XCTAssertEqualObjects([[self unpack:before] subarrayWithRange:NSMakeRange(1, 5)], + (@[ @1, @110, @220, @160, @270 ])); + + XCTAssertFalse([_store setCoordinates:NULL count:0 forDisplay:0 surface:42 runtime:old]); + + XCTAssertEqualObjects([_store snapshotForDisplay:0], before); +} + +- (void)testTheCurrentRuntimesWritesStillApply { + const int32_t rects[] = {100, 200, 150, 250}; + [_store setDisplaySize:CGSizeMake(402, 874) forDisplay:0]; + [_store claimRuntime]; + const NSInteger current = [_store claimRuntime]; + XCTAssertEqual([_store currentClaim], current); + + XCTAssertTrue([_store setCoordinates:rects count:4 forDisplay:0 surface:42 runtime:current]); + [_store setOrigin:CGPointMake(10, 20) forDisplay:0 surface:42]; + XCTAssertEqualObjects([[self unpack:[_store snapshotForDisplay:0]] subarrayWithRange:NSMakeRange(1, 5)], + (@[ @1, @110, @220, @160, @270 ])); + + XCTAssertTrue([_store setCoordinates:NULL count:0 forDisplay:0 surface:42 runtime:current]); + XCTAssertEqualObjects([self unpack:[_store snapshotForDisplay:0]][1], @0); +} + +/// A malformed write from the current runtime is still rejected, not applied. +- (void)testTheCurrentRuntimesMalformedWriteIsStillRejected { + const int32_t rects[] = {1, 2, 3}; + const NSInteger current = [_store claimRuntime]; + + XCTAssertFalse([_store setCoordinates:rects count:3 forDisplay:0 surface:42 runtime:current]); + XCTAssertEqualObjects([_store surfacesForDisplay:0], @[]); +} + /// The shared store outlives any one wrapper: the init provider registers one /// before launch and setWrapperInfo swaps in another, and the regions the app /// marked secret must survive that. From f0a421bef6a0c5fbeb2be9c12d614d550ff43eaa Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Mon, 5 Oct 2026 16:31:24 +0500 Subject: [PATCH 15/19] fix(ios): find a Modal host by class and React tag, not viewWithTag: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit viewWithTag: returns the first view in a window with that tag. A native view with the same small integer ahead of the RCTModalHostViewComponentView made the lookup skip the rest of that window, so the Modal's origin was never read and its lane failed closed over the whole screen. BGSRNTaggedView walks each window breadth-first, bounded per window, and matches a view that is both the host class and has the tag; a wrong-class view with the tag does not end the search. The Fabric registry is not used: a module reaches it only through viewRegistry_DEPRECATED, and the lookup runs in the wrapper's pull, which has no module. The read stays on main, behind the existing weak host cache. Addresses PR 48 review thread 4182289985. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --- packages/react-native/ios/BugseeModule.mm | 23 +++-- .../BugseeRNSupport/BGSRNReactWindow.m | 17 ++++ .../include/BGSRNReactWindow.h | 19 ++++ .../BGSRNReactWindowTests.m | 92 +++++++++++++++++++ 4 files changed, 143 insertions(+), 8 deletions(-) diff --git a/packages/react-native/ios/BugseeModule.mm b/packages/react-native/ios/BugseeModule.mm index 74567f91..35b45762 100644 --- a/packages/react-native/ios/BugseeModule.mm +++ b/packages/react-native/ios/BugseeModule.mm @@ -282,7 +282,15 @@ - (UIViewController *)viewController; /// The mounted `` host component view with React tag `surface`, among /// the windows the SDK walks. Fabric sets each component view's `tag` to its -/// React tag (`RCTComponentViewRegistry`). nil when there is none. +/// React tag (`RCTComponentViewRegistry`), but a window's tags are not a +/// React namespace: a native view can carry the same integer. So the walk +/// matches the class AND the tag, and a wrong-class view with that tag does +/// not end it (`BGSRNTaggedView`), where `viewWithTag:` stopped at the first. +/// Not the Fabric registry: a module reaches it only through +/// `viewRegistry_DEPRECATED` (bridgeless backs it with the surface +/// presenter's `findComponentViewWithTag_DO_NOT_USE_DEPRECATED:`), and the +/// pull path that calls this is the wrapper's, which has no module. nil when +/// there is none. static UIView *_Nullable BGSRNModalHost(NSInteger surface) { static Class modalHostClass; static dispatch_once_t once; @@ -297,13 +305,12 @@ - (UIViewController *)viewController; if (cached != nil && cached.tag == surface && cached.window != nil) { return cached; } - UIWindow *keyWindow = BGSRNSdkKeyWindow(); - for (UIWindow *window in BGSRNSdkWalkedWindows(keyWindow)) { - UIView *found = [window viewWithTag:surface]; - if (found != nil && [found isKindOfClass:modalHostClass]) { - [BGSRNModalHosts() setObject:found forKey:key]; - return found; - } + UIView *found = BGSRNTaggedView(BGSRNSdkWalkedWindows(BGSRNSdkKeyWindow()), surface, ^BOOL(UIView *view) { + return [view isKindOfClass:modalHostClass]; + }, BGSRNModalHostSearchBudget); + if (found != nil) { + [BGSRNModalHosts() setObject:found forKey:key]; + return found; } [BGSRNModalHosts() removeObjectForKey:key]; return nil; diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactWindow.m b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactWindow.m index db4b6d0a..259f336f 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactWindow.m +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactWindow.m @@ -2,6 +2,8 @@ const NSUInteger BGSRNReactRootSearchBudget = 2000; +const NSUInteger BGSRNModalHostSearchBudget = 10000; + UIWindow *BGSRNSdkKeyWindow(void) { UIApplication *application = UIApplication.sharedApplication; if (application == nil) { @@ -100,3 +102,18 @@ static BOOL HostsReactRoot(UIWindow *window, BOOL (^isReactRoot)(UIView *), NSUI // space: the SDK adds exactly this (BGSCaptureViewHierarchyEngine.m:335-336). return [NSValue valueWithCGPoint:window.frame.origin]; } + +UIView *BGSRNTaggedView(NSArray *windows, NSInteger tag, BOOL (^matches)(UIView *), NSUInteger budget) { + for (UIWindow *window in windows) { + NSMutableArray *queue = [NSMutableArray arrayWithObject:window]; + NSUInteger head = 0; + while (head < queue.count && head < budget) { + UIView *view = queue[head++]; + if (view.tag == tag && matches(view)) { + return view; + } + [queue addObjectsFromArray:view.subviews]; + } + } + return nil; +} diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactWindow.h b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactWindow.h index 41ffda81..f0fd25a1 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactWindow.h +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactWindow.h @@ -50,4 +50,23 @@ FOUNDATION_EXPORT NSValue *_Nullable BGSRNReactRootOrigin(UIWindow *_Nullable ke NSArray *windows, BOOL (^isReactRoot)(UIView *view)); +/// How many views `BGSRNTaggedView` 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 a +/// pathological native hierarchy from costing the main thread on every pull. +FOUNDATION_EXPORT const NSUInteger BGSRNModalHostSearchBudget; + +/// The first view whose `tag` is `tag` and that `matches` accepts, searching +/// each of `windows` in order, breadth-first, over at most `budget` views per +/// window. A view with that tag that `matches` rejects does not end the +/// search, in its window or any other: Fabric sets a component view's `tag` +/// to its React tag, but it does not own every `tag` in a window, so a native +/// view (or another runtime's) can carry the same small integer. Unlike +/// `-[UIView viewWithTag:]`, which returns the first view with the tag +/// whatever it is. nil when none. +FOUNDATION_EXPORT UIView *_Nullable BGSRNTaggedView(NSArray *windows, + NSInteger tag, + BOOL (^matches)(UIView *view), + NSUInteger budget); + NS_ASSUME_NONNULL_END diff --git a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactWindowTests.m b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactWindowTests.m index cabd2e4f..066d47aa 100644 --- a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactWindowTests.m +++ b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactWindowTests.m @@ -10,6 +10,14 @@ @interface BGSRNFakeReactRoot : UIView @implementation BGSRNFakeReactRoot @end +/// Stands in for `RCTModalHostViewComponentView`; the module's predicate +/// matches the real class by name. +@interface BGSRNFakeModalHost : UIView +@end + +@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 @@ -121,4 +129,88 @@ - (void)testTheSdkWalkedWindowsAreEmptyWithoutAKeyWindow { XCTAssertEqualObjects(BGSRNSdkWalkedWindows(nil), @[]); } +#pragma mark - The host by React tag (PR 48) + +- (BOOL (^)(UIView *))isModalHost { + return ^BOOL(UIView *view) { + return [view isKindOfClass:BGSRNFakeModalHost.class]; + }; +} + +/// A native view with the host's tag, earlier in the same window, is what +/// `viewWithTag:` returns; the walk must keep going and find the host. +- (void)testANativeViewWithTheSameTagBeforeTheHostStillFindsTheHost { + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 390, 844)]; + UIView *dummy = [UIView new]; + dummy.tag = 42; + [window addSubview:dummy]; + UIView *container = [UIView new]; + [window addSubview:container]; + BGSRNFakeModalHost *host = [BGSRNFakeModalHost new]; + host.tag = 42; + [container addSubview:host]; + XCTAssertEqual([window viewWithTag:42], dummy, @"the fixture must put the dummy first"); + + UIView *found = BGSRNTaggedView(@[ window ], 42, [self isModalHost], BGSRNModalHostSearchBudget); + + XCTAssertEqual(found, host); +} + +/// Same when the dummy is the host's own ancestor. +- (void)testAnAncestorWithTheSameTagStillFindsTheHost { + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 390, 844)]; + UIView *dummy = [UIView new]; + dummy.tag = 7; + [window addSubview:dummy]; + BGSRNFakeModalHost *host = [BGSRNFakeModalHost new]; + host.tag = 7; + [dummy addSubview:host]; + + XCTAssertEqual(BGSRNTaggedView(@[ window ], 7, [self isModalHost], BGSRNModalHostSearchBudget), host); +} + +/// A wrong-class match in one window does not stop the search of the next. +- (void)testAWrongClassMatchInOneWindowStillSearchesTheNext { + UIWindow *first = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 390, 844)]; + UIView *dummy = [UIView new]; + dummy.tag = 42; + [first addSubview:dummy]; + UIWindow *second = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 390, 844)]; + BGSRNFakeModalHost *host = [BGSRNFakeModalHost new]; + host.tag = 42; + [second addSubview:host]; + + XCTAssertEqual(BGSRNTaggedView(@[ first, second ], 42, [self isModalHost], BGSRNModalHostSearchBudget), host); +} + +/// The class alone is not enough: another Modal's host has another tag. +- (void)testAHostWithAnotherTagIsNotAMatch { + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 390, 844)]; + BGSRNFakeModalHost *other = [BGSRNFakeModalHost new]; + other.tag = 43; + [window addSubview:other]; + UIView *dummy = [UIView new]; + dummy.tag = 42; + [window addSubview:dummy]; + + XCTAssertNil(BGSRNTaggedView(@[ window ], 42, [self isModalHost], BGSRNModalHostSearchBudget)); +} + +- (void)testTheTaggedSearchIsBoundedPerWindow { + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 390, 844)]; + UIView *parent = window; + for (NSUInteger i = 0; i < 5; i++) { + UIView *child = [UIView new]; + [parent addSubview:child]; + parent = child; + } + BGSRNFakeModalHost *host = [BGSRNFakeModalHost new]; + host.tag = 42; + [parent addSubview:host]; + + // The window, five levels, then the host: the seventh view visited. + XCTAssertNil(BGSRNTaggedView(@[ window ], 42, [self isModalHost], 6)); + XCTAssertEqual(BGSRNTaggedView(@[ window ], 42, [self isModalHost], 7), host); +} + @end From e3463b7dc0382ed53926a0782cb7c260c5e237b6 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Mon, 5 Oct 2026 18:20:28 +0500 Subject: [PATCH 16/19] fix(android): a runtime claim empties the main surface's rectangles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once stale writes are ignored, the old runtime's unmount [] cannot clear the main set, so a new tree that never publishes on the main surface kept masking where the old was until the process died. claimRuntime now empties the main lane's rectangles and keeps its origin, so the new runtime's first publish is the only main set. Stale empty writes stay ignored. Addresses PR 48 review thread 4184201603. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --- .../reactnative/SecureRectangleStore.java | 34 ++++++++++++++-- .../reactnative/SecureRectangleStoreTest.java | 40 ++++++++++++++++++- 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/packages/react-native/android/src/main/java/com/bugsee/reactnative/SecureRectangleStore.java b/packages/react-native/android/src/main/java/com/bugsee/reactnative/SecureRectangleStore.java index e1d8b2d8..d7678641 100644 --- a/packages/react-native/android/src/main/java/com/bugsee/reactnative/SecureRectangleStore.java +++ b/packages/react-native/android/src/main/java/com/bugsee/reactnative/SecureRectangleStore.java @@ -56,7 +56,12 @@ * tag that the next runtime does not know. A reload drops every surface but * the main one ({@link #claimRuntime}, {@link #releaseRuntime}), so a Modal * that was open when the old runtime went away does not mask the display - * for the rest of the process. + * for the rest of the process. A claim also empties the main surface's + * rectangles, keeping its origin: they were the old runtime's, and its + * clearing write is ignored once stale (below), so without this a new tree + * that never publishes on the main surface would leave them masking until + * the process dies. The new runtime's first publish is then the only main + * set. * *

The claim also gates every lane write a runtime makes: the module's * publish ({@link #publishForRuntime}) and its tracker's origin writes @@ -423,16 +428,37 @@ private boolean isCurrentLocked(final int claim) { /** * A new JS runtime's module is starting: drops every surface but the main - * one, whose keys the new runtime cannot know, and returns its claim. - * Synchronised with {@link #releaseRuntime} so an old module released - * after the new one started cannot drop the new runtime's surfaces. + * one, whose keys the new runtime cannot know, empties the main one's + * rectangles (keeping its origin), and returns its claim. Synchronised + * with {@link #releaseRuntime} so an old module released after the new + * one started cannot drop the new runtime's surfaces. */ synchronized int claimRuntime() { runtime++; dropNonMainSurfaces(); + emptyMainSurface(); return runtime; } + /** + * Empties the main surface's rectangles on every display, keeping its + * origin: the old runtime's set must not outlive it, and a new runtime + * that never publishes on the main surface would otherwise inherit it. + */ + private void emptyMainSurface() { + for (final Integer display : byDisplay.keySet()) { + update(display, previous -> { + final Lane main = previous.lanes.get(MAIN_SURFACE); + if (main == null || main.raw.length == 0) { + return previous; + } + final TreeMap nextLanes = new TreeMap<>(previous.lanes); + nextLanes.put(MAIN_SURFACE, main.withRaw(NO_COORDINATES)); + return previous.withLanes(nextLanes); + }); + } + } + /** * The module holding {@code claim} is gone. Drops every surface but the * main one, unless a newer runtime has already claimed the store. diff --git a/packages/react-native/android/src/test/java/com/bugsee/reactnative/SecureRectangleStoreTest.java b/packages/react-native/android/src/test/java/com/bugsee/reactnative/SecureRectangleStoreTest.java index a6a235f4..bf4b3c3a 100644 --- a/packages/react-native/android/src/test/java/com/bugsee/reactnative/SecureRectangleStoreTest.java +++ b/packages/react-native/android/src/test/java/com/bugsee/reactnative/SecureRectangleStoreTest.java @@ -474,7 +474,10 @@ public void hasRectanglesIsTrueOnlyForASurfaceThatHoldsSome() { // ---- A JS reload (N7) ------------------------------------------------ - /** A new runtime drops the Modal surfaces the old one left; the main one stays. */ + /** + * A new runtime drops the Modal surfaces the old one left; the main one + * stays, emptied, with its origin. + */ @Test public void claimingANewRuntimeDropsEveryModalSurfaceButKeepsTheMainOne() { final SecureRectangleStore store = located(); @@ -495,10 +498,43 @@ public void claimingANewRuntimeDropsEveryModalSurfaceButKeepsTheMainOne() { assertTrue(store.hasSurface(MAIN_SURFACE)); final int[] packed = store.snapshot(DISPLAY); assertNotEquals(before, packed[0]); - assertArrayEquals(new int[] { 10, 20, 30, 40 }, rectanglesOf(packed)); + assertEquals(0, packed[1]); assertEquals(0, store.snapshot(1)[1]); } + /** The main surface's origin survives the claim: the new runtime's first set is placed, not failed closed. */ + @Test + public void aClaimKeepsTheMainSurfacesOrigin() { + final SecureRectangleStore store = new SecureRectangleStore(); + store.setDisplayBounds(DISPLAY, WIDTH, HEIGHT); + store.setOrigin(DISPLAY, 5, 7); + store.set(DISPLAY, new int[] { 10, 20, 30, 40 }); + + final int current = store.claimRuntime(); + assertTrue(store.publishForRuntime(current, DISPLAY, MAIN_SURFACE, new int[] { 1, 2, 3, 4 })); + + assertArrayEquals(new int[] { 6, 9, 8, 11 }, rectanglesOf(store.snapshot(DISPLAY))); + } + + /** + * The old runtime's main set must not outlive it: its clearing write is + * stale, and a new tree that never publishes on the main surface would + * otherwise keep masking where the old secure view was. + */ + @Test + public void aClaimWithAMainSetThenAStaleEmptyWriteAndNoNewPublishServesNothing() { + final SecureRectangleStore store = located(); + store.setDisplayBounds(DISPLAY, WIDTH, HEIGHT); + final int old = store.claimRuntime(); + store.publishForRuntime(old, DISPLAY, MAIN_SURFACE, new int[] { 10, 20, 30, 40 }); + assertEquals(1, store.snapshot(DISPLAY)[1]); + + store.claimRuntime(); + assertFalse(store.publishForRuntime(old, DISPLAY, MAIN_SURFACE, new int[0])); + + assertEquals(0, store.snapshot(DISPLAY)[1]); + } + @Test public void releasingTheCurrentRuntimeDropsItsModalSurfaces() { final SecureRectangleStore store = located(); From 86a0b7561bbcfeec8d89308eac5513450d55af79 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Mon, 5 Oct 2026 18:21:43 +0500 Subject: [PATCH 17/19] fix(ios): a runtime claim empties the main surface's rectangles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The iOS twin of the Android fix: claimRuntime empties the main lane's rectangles and keeps its origin, so the old runtime's main set cannot outlive it when its clearing write is ignored as stale and the new tree never publishes on the main surface. Stale empty writes stay ignored. Addresses PR 48 review thread 4184201603. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --- .../BugseeRNSupport/BGSRNSecureRectangles.m | 21 +++++++++++ .../include/BGSRNSecureRectangles.h | 9 ++++- .../BGSRNSecureRectanglesTests.m | 37 ++++++++++++++++++- 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m index 2cd1698e..349e90a7 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m @@ -234,10 +234,31 @@ - (NSInteger)claimRuntime { _runtime += 1; const NSInteger claim = _runtime; [self dropNonMainSurfacesLocked]; + [self emptyMainSurfaceLocked]; [_lock unlock]; return claim; } +/// Empties the main surface's rectangles on every display, keeping its +/// origin: the old runtime's set must not outlive it, and a new runtime that +/// never publishes on the main surface would otherwise inherit it. Caller +/// holds `_lock`. +- (void)emptyMainSurfaceLocked { + for (NSNumber *display in _lanesByDisplay.allKeys) { + BGSRNSecureLane *main = _lanesByDisplay[display][@(BGSRNSecureMainSurface)]; + if (main == nil || main.raw.length == 0) { + continue; + } + [self changeLaneLockedForDisplay:display.integerValue + surface:BGSRNSecureMainSurface + change:^BGSRNSecureLane *(BGSRNSecureLane *prior) { + return [[BGSRNSecureLane alloc] initWithRaw:[NSData data] + origin:prior.origin + originKnown:prior.originKnown]; + }]; + } +} + - (void)releaseRuntime:(NSInteger)claim { [_lock lock]; if (claim == _runtime) { 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 9c0f79bd..68612f69 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSecureRectangles.h +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSecureRectangles.h @@ -52,7 +52,11 @@ FOUNDATION_EXPORT const int32_t BGSRNSecureFallbackDisplaySize; /// A Modal surface belongs to one JS runtime: its key is a React tag the next /// runtime does not know. A reload drops every surface but the main one /// (`claimRuntime`, `releaseRuntime:`), so a Modal that was open when the old -/// runtime went away does not mask the screen for the rest of the process. +/// runtime went away does not mask the screen for the rest of the process. A +/// claim also empties the main surface's rectangles, keeping its origin: they +/// were the old runtime's, and its clearing write is ignored once stale +/// (below), so a new tree that never publishes on the main surface would +/// otherwise leave them masking until the process dies. /// /// The claim also gates the module's rectangle writes /// (`setCoordinates:count:forDisplay:surface:runtime:`): one whose claim is no @@ -131,7 +135,8 @@ FOUNDATION_EXPORT const int32_t BGSRNSecureFallbackDisplaySize; - (NSArray *)surfacesForDisplay:(NSInteger)display; /// A new JS runtime's module is starting: drops every surface but the main -/// one and returns its claim. Serialised with `releaseRuntime:`, so an old +/// one, empties the main one's rectangles (keeping its origin), and returns +/// its claim. Serialised with `releaseRuntime:`, so an old /// module released after the new one started cannot drop its surfaces. - (NSInteger)claimRuntime; diff --git a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m index 8708c894..fe2bd7ab 100644 --- a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m +++ b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m @@ -325,7 +325,8 @@ - (void)testDropsOnlyAnEmptyNonMainSurface { XCTAssertEqualObjects([self unpack:[_store snapshotForDisplay:0]][0], before); } -/// A new runtime drops the Modal surfaces the old one left; the main one stays. +/// A new runtime drops the Modal surfaces the old one left; the main one +/// stays, emptied, with its origin. - (void)testClaimingANewRuntimeDropsEveryModalSurfaceButKeepsTheMainOne { const int32_t mainRects[] = {10, 20, 30, 40}; const int32_t modalRects[] = {100, 200, 150, 250}; @@ -341,7 +342,39 @@ - (void)testClaimingANewRuntimeDropsEveryModalSurfaceButKeepsTheMainOne { XCTAssertEqualObjects([_store surfacesForDisplay:1], @[]); NSArray *packed = [self unpack:[_store snapshotForDisplay:0]]; XCTAssertNotEqualObjects(packed[0], before); - XCTAssertEqualObjects([packed subarrayWithRange:NSMakeRange(1, 5)], (@[ @1, @10, @20, @30, @40 ])); + XCTAssertEqualObjects(packed[1], @0); +} + +/// The main surface's origin survives the claim: the new runtime's first set +/// is placed, not failed closed. +- (void)testAClaimKeepsTheMainSurfacesOrigin { + const int32_t oldRects[] = {10, 20, 30, 40}; + const int32_t newRects[] = {1, 2, 3, 4}; + [_store setDisplaySize:CGSizeMake(402, 874) forDisplay:0]; + [_store setOrigin:CGPointMake(5, 7) forDisplay:0]; + [_store setCoordinates:oldRects count:4 forDisplay:0]; + + const NSInteger current = [_store claimRuntime]; + XCTAssertTrue([_store setCoordinates:newRects count:4 forDisplay:0 surface:BGSRNSecureMainSurface runtime:current]); + + XCTAssertEqualObjects([[self unpack:[_store snapshotForDisplay:0]] subarrayWithRange:NSMakeRange(1, 5)], + (@[ @1, @6, @9, @8, @11 ])); +} + +/// The old runtime's main set must not outlive it: its clearing write is +/// stale, and a new tree that never publishes on the main surface would +/// otherwise keep masking where the old secure view was. +- (void)testAClaimWithAMainSetThenAStaleEmptyWriteAndNoNewPublishServesNothing { + const int32_t rects[] = {10, 20, 30, 40}; + [_store setDisplaySize:CGSizeMake(402, 874) forDisplay:0]; + const NSInteger old = [_store claimRuntime]; + [_store setCoordinates:rects count:4 forDisplay:0 surface:BGSRNSecureMainSurface runtime:old]; + XCTAssertEqualObjects([self unpack:[_store snapshotForDisplay:0]][1], @1); + + [_store claimRuntime]; + XCTAssertFalse([_store setCoordinates:NULL count:0 forDisplay:0 surface:BGSRNSecureMainSurface runtime:old]); + + XCTAssertEqualObjects([self unpack:[_store snapshotForDisplay:0]][1], @0); } - (void)testReleasingTheCurrentRuntimeDropsItsModalSurfaces { From 311de7cc8fb3c175b04db533f48d1dd89402a6df Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Mon, 5 Oct 2026 18:33:29 +0500 Subject: [PATCH 18/19] fix(privacy,ios): place a Modal lane by the current runtime's own host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pull found a Modal's origin by searching the windows for a host with the lane's React tag, and cached the first hit. During a reload Fabric numbers tags from 1 again, so the old runtime's still-presented host can share the tag, and its origin (a sheet inset, say) would place the new runtime's rectangles somewhere else: a leak, not a blackout. The module now names its own runtime's lookup with each Modal publish: its viewRegistry_DEPRECATED (RCTViewRegistry, per runtime), claim-gated in the store like every other write. Fabric mounts the host after JS has published inside it, so the pull asks that lookup on main until it finds an RCTModalHostViewComponentView with the tag, then holds it weakly and reads the origin from it, the shape of Android's watchSurface. A claim forgets every host and lookup; a surface's go with its last lane. Without a registry, or while it finds nothing, the pull falls back to the bounded class+tag walk, which now gives no origin for several matches or for a match that is not presented: the lane stays unknown and is served as the whole display. The weak host cache is gone. Addresses PR 48 review thread 4184201590. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --- packages/react-native/ios/BugseeModule.mm | 132 +++++------ .../BugseeRNSupport/BGSRNReactWindow.m | 54 ++++- .../BugseeRNSupport/BGSRNSecureRectangles.m | 96 ++++++++ .../include/BGSRNReactWindow.h | 58 +++-- .../include/BGSRNSecureRectangles.h | 38 +++- .../BGSRNReactWindowTests.m | 212 +++++++++++++++++- .../BGSRNSecureRectanglesTests.m | 94 ++++++++ 7 files changed, 578 insertions(+), 106 deletions(-) diff --git a/packages/react-native/ios/BugseeModule.mm b/packages/react-native/ios/BugseeModule.mm index 35b45762..05c8cd5b 100644 --- a/packages/react-native/ios/BugseeModule.mm +++ b/packages/react-native/ios/BugseeModule.mm @@ -69,6 +69,7 @@ /// functions' definitions later in this file. static NSValue *_Nullable BGSRNReactOrigin(void); static NSValue *_Nullable BGSRNModalSurfaceOrigin(NSInteger surface); +static Class _Nullable BGSRNModalHostClass(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 @@ -261,93 +262,39 @@ static void BGSRNSetWrapper(id _Nullable wrapper, BOOL onlyIfAbse }); } -/// A `` host's view controller. Declared here rather than imported: -/// `RCTModalHostViewComponentView` implements `viewController` without -/// declaring it in its header, and the call is guarded by -/// `respondsToSelector:`. -@interface UIView (BGSRNModalHost) -- (UIViewController *)viewController; -@end - -/// The `` host component views found so far, by React tag. Weak: a -/// host that unmounts must not be kept alive. Main thread only. -static NSMapTable *BGSRNModalHosts(void) { - static NSMapTable *hosts; - static dispatch_once_t once; - dispatch_once(&once, ^{ - hosts = [NSMapTable strongToWeakObjectsMapTable]; - }); - return hosts; -} - -/// The mounted `` host component view with React tag `surface`, among -/// the windows the SDK walks. Fabric sets each component view's `tag` to its -/// React tag (`RCTComponentViewRegistry`), but a window's tags are not a -/// React namespace: a native view can carry the same integer. So the walk -/// matches the class AND the tag, and a wrong-class view with that tag does -/// not end it (`BGSRNTaggedView`), where `viewWithTag:` stopped at the first. -/// Not the Fabric registry: a module reaches it only through -/// `viewRegistry_DEPRECATED` (bridgeless backs it with the surface -/// presenter's `findComponentViewWithTag_DO_NOT_USE_DEPRECATED:`), and the -/// pull path that calls this is the wrapper's, which has no module. nil when -/// there is none. -static UIView *_Nullable BGSRNModalHost(NSInteger surface) { +/// `RCTModalHostViewComponentView`, resolved by name rather than imported; +/// Nil when this React Native has no such class. +static Class _Nullable BGSRNModalHostClass(void) { static Class modalHostClass; static dispatch_once_t once; dispatch_once(&once, ^{ modalHostClass = NSClassFromString(@"RCTModalHostViewComponentView"); }); - if (modalHostClass == Nil || surface <= 0) { - return nil; - } - NSNumber *key = @(surface); - UIView *cached = [BGSRNModalHosts() objectForKey:key]; - if (cached != nil && cached.tag == surface && cached.window != nil) { - return cached; - } - UIView *found = BGSRNTaggedView(BGSRNSdkWalkedWindows(BGSRNSdkKeyWindow()), surface, ^BOOL(UIView *view) { - return [view isKindOfClass:modalHostClass]; - }, BGSRNModalHostSearchBudget); - if (found != nil) { - [BGSRNModalHosts() setObject:found forKey:key]; - return found; - } - [BGSRNModalHosts() removeObjectForKey:key]; - return nil; + return modalHostClass; } /// Where `measureInWindow`'s (0, 0) sits on the screen, in points, for the -/// `` whose host has React tag `surface`: the origin of its presented -/// view controller's view in its window, plus the window's `frame.origin`. -/// 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 the Modal is not presented, or off main. -/// -/// 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. +/// `` whose host has React tag `surface` (`BGSRNModalHostOrigin`). +/// Read from the current runtime's host, which its module names at publish +/// time (`nameModalHostForSurface:`), the same shape as Android's +/// `watchSurface`: a window can hold another runtime's host with the same +/// tag during a reload, and only the module's own view registry tells them +/// apart. Without a recorded host, the one class+tag match in the windows +/// the SDK walks. nil (none or several, or off main) leaves the lane's +/// origin as it was: unknown for a new lane, which is served as the whole +/// display. static NSValue *_Nullable BGSRNModalSurfaceOrigin(NSInteger surface) { if (!NSThread.isMainThread) { return nil; } - UIView *host = BGSRNModalHost(surface); - if (host == nil || ![host respondsToSelector:@selector(viewController)]) { - return nil; - } - UIViewController *controller = [host viewController]; - if (controller == nil || !controller.isViewLoaded) { - return nil; - } - UIView *content = controller.view; - UIWindow *window = content.window; - if (window == nil) { + Class modalHostClass = BGSRNModalHostClass(); + if (modalHostClass == Nil || surface <= 0) { return nil; } - CGPoint inWindow = [content convertPoint:CGPointZero toView:nil]; - CGPoint origin = CGPointMake(inWindow.x + window.frame.origin.x, inWindow.y + window.frame.origin.y); - return [NSValue valueWithCGPoint:origin]; + return BGSRNSecureSurfaceOrigin(BGSRNSecureRectangles.shared, surface, + BGSRNSdkWalkedWindows(BGSRNSdkKeyWindow()), ^BOOL(UIView *view) { + return [view isKindOfClass:modalHostClass]; + }); } static NSString *const kHandleDeadCode = @"E_REPORT_HANDLE_DEAD"; @@ -923,6 +870,10 @@ @implementation BugseeModule { } @synthesize spanHandles = _spanHandles; +/// This runtime's view registry (set by React Native through +/// `RCTBridgeModuleDecorator`): how a `` host is found by its React +/// tag without searching windows another runtime shares. +@synthesize viewRegistry_DEPRECATED = _viewRegistry_DEPRECATED; RCT_EXPORT_MODULE(Bugsee) @@ -1106,12 +1057,37 @@ - (void)setSecureRectanglesOnSurface:(double)display // Ignored once a newer runtime has claimed the store: this module's late // writes (a reload's teardown) must not touch the next runtime's lanes, // whose keys may be the same React tags. - [BGSRNSecureRectangles.shared setCoordinates:flat - count:count - forDisplay:(NSInteger)display - surface:(NSInteger)llround(surface) - runtime:_secureRuntime]; + const NSInteger key = (NSInteger)llround(surface); + const BOOL published = [BGSRNSecureRectangles.shared setCoordinates:flat + count:count + forDisplay:(NSInteger)display + surface:key + runtime:_secureRuntime]; free(flat); + // 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. + if (published && count > 0 && key != BGSRNSecureMainSurface) { + [self nameModalHostForSurface:key]; + } +} + +/// Names, under this module's claim, how the store finds the `` host +/// with React tag `surface`: this runtime's view registry, which another +/// runtime's same-tag host is not in. Not looked up here: Fabric mounts the +/// host after JS has measured and published inside it, so the pull asks (on +/// main) until the host is there, then holds it weakly. No registry: nothing +/// is named, and the pull falls back to a unique class+tag match. +- (void)nameModalHostForSurface:(NSInteger)surface { + __weak RCTViewRegistry *registry = self.viewRegistry_DEPRECATED; + if (registry == nil) { + return; + } + [BGSRNSecureRectangles.shared setHostResolver:^id _Nullable(NSInteger tag) { + return [registry viewForReactTag:@(tag)]; + } + forSurface:surface + runtime:_secureRuntime]; } /// The screen origin `[x, y]` (points) of the `` whose host has React diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactWindow.m b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactWindow.m index 259f336f..35be74fb 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactWindow.m +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactWindow.m @@ -103,17 +103,65 @@ static BOOL HostsReactRoot(UIWindow *window, BOOL (^isReactRoot)(UIView *), NSUI return [NSValue valueWithCGPoint:window.frame.origin]; } -UIView *BGSRNTaggedView(NSArray *windows, NSInteger tag, BOOL (^matches)(UIView *), NSUInteger budget) { +/// A `` host's view controller. Declared here rather than imported: +/// `RCTModalHostViewComponentView` implements `viewController` without +/// declaring it in its header, and the call is guarded by +/// `respondsToSelector:`. +@interface UIView (BGSRNModalHostController) +- (UIViewController *)viewController; +@end + +UIView *BGSRNUniqueTaggedView(NSArray *windows, NSInteger tag, BOOL (^matches)(UIView *), NSUInteger budget) { + UIView *found = nil; for (UIWindow *window in windows) { NSMutableArray *queue = [NSMutableArray arrayWithObject:window]; NSUInteger head = 0; while (head < queue.count && head < budget) { UIView *view = queue[head++]; if (view.tag == tag && matches(view)) { - return view; + if (found != nil && found != view) { + return nil; + } + found = view; } [queue addObjectsFromArray:view.subviews]; } } - return nil; + return found; +} + +NSValue *BGSRNModalHostOrigin(UIView *host) { + if (![host respondsToSelector:@selector(viewController)]) { + return nil; + } + UIViewController *controller = [host viewController]; + if (controller == nil || !controller.isViewLoaded) { + return nil; + } + UIView *content = controller.view; + UIWindow *window = content.window; + 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. + CGPoint inWindow = [content convertPoint:CGPointZero toView:nil]; + return [NSValue valueWithCGPoint:CGPointMake(inWindow.x + window.frame.origin.x, inWindow.y + window.frame.origin.y)]; +} + +NSValue *BGSRNSecureSurfaceOrigin(BGSRNSecureRectangles *store, + NSInteger surface, + NSArray *windows, + BOOL (^isHost)(UIView *)) { + 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); + } + UIView *found = BGSRNUniqueTaggedView(windows, surface, isHost, BGSRNModalHostSearchBudget); + return found == nil ? nil : BGSRNModalHostOrigin(found); } diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m index 349e90a7..4e9dde71 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m @@ -83,6 +83,18 @@ - (instancetype)initWithRaw:(NSData *)raw origin:(CGPoint)origin originKnown:(BO @end +/// A Modal surface's host, as its owning module named it: the host itself, +/// held weakly once found (a host that unmounts must not be kept alive), and +/// the module's lookup that finds it, with that module's claim. +@interface BGSRNSurfaceHost : NSObject +@property (nonatomic, weak) id host; +@property (nonatomic, copy, nullable) BGSRNSecureHostResolver resolver; +@property (nonatomic, assign) NSInteger claim; +@end + +@implementation BGSRNSurfaceHost +@end + @implementation BGSRNSecureRectangles { /// display -> (surface -> lane). Served in surface-key order. NSMutableDictionary *> *_lanesByDisplay; @@ -96,6 +108,10 @@ @implementation BGSRNSecureRectangles { NSLock *_lock; /// The current JS runtime's claim; see `claimRuntime`. Under `_lock`. NSInteger _runtime; + /// surface -> its host as the owning module named it (`setHost:...`, + /// `setHostResolver:...`). Under `_lock`. An entry goes with the surface's + /// last lane, and on a claim. + NSMutableDictionary *_hostsBySurface; } + (BGSRNSecureRectangles *)shared { @@ -114,6 +130,7 @@ - (instancetype)init { _versionsByDisplay = [NSMutableDictionary dictionary]; _boundsByDisplay = [NSMutableDictionary dictionary]; _lock = [[NSLock alloc] init]; + _hostsBySurface = [NSMutableDictionary dictionary]; } return self; } @@ -280,6 +297,82 @@ - (void)dropNonMainSurfacesLocked { } [self moveVersionLockedForDisplay:display.integerValue ifServedDiffersFrom:previousServed]; } + // Every host recorded so far belongs to a runtime whose lanes just went. + [_hostsBySurface removeAllObjects]; +} + +- (BOOL)setHost:(id)host forSurface:(NSInteger)surface runtime:(NSInteger)claim { + if (host == nil) { + return NO; + } + [_lock lock]; + BGSRNSurfaceHost *entry = [self hostEntryLockedForSurface:surface runtime:claim]; + entry.host = host; + [_lock unlock]; + return entry != nil; +} + +- (BOOL)setHostResolver:(BGSRNSecureHostResolver)resolver forSurface:(NSInteger)surface runtime:(NSInteger)claim { + if (resolver == nil) { + return NO; + } + [_lock lock]; + BGSRNSurfaceHost *entry = [self hostEntryLockedForSurface:surface runtime:claim]; + entry.resolver = resolver; + [_lock unlock]; + return entry != nil; +} + +/// `surface`'s host entry for the module holding `claim`, made if needed; +/// nil when the claim is stale, `surface` is the main one, or no display has +/// a lane for it. Caller holds `_lock`. +- (nullable BGSRNSurfaceHost *)hostEntryLockedForSurface:(NSInteger)surface runtime:(NSInteger)claim { + if (surface == BGSRNSecureMainSurface || ![self isCurrentLocked:claim] || ![self hasLaneLockedForSurface:surface]) { + return nil; + } + BGSRNSurfaceHost *entry = _hostsBySurface[@(surface)]; + if (entry == nil) { + entry = [BGSRNSurfaceHost new]; + entry.claim = claim; + _hostsBySurface[@(surface)] = entry; + } + return entry; +} + +- (id)hostForSurface:(NSInteger)surface accepting:(BOOL (^)(id candidate))accept { + [_lock lock]; + BGSRNSurfaceHost *entry = _hostsBySurface[@(surface)]; + id host = entry.host; + BGSRNSecureHostResolver resolver = entry.resolver; + const NSInteger claim = entry.claim; + [_lock unlock]; + if (host != nil || resolver == nil) { + return host; + } + // Outside the lock: the lookup is the module's, and may take its own. + id candidate = nil; + @try { + candidate = resolver(surface); + } @catch (NSException *exception) { + NSLog(@"BugseeRN secure host lookup threw: %@", NSStringFromClass(exception.class)); + return nil; + } + if (candidate == nil || !accept(candidate)) { + return nil; + } + // Pinned under the claim it was named with: a newer runtime's claim since + // the lookup started forgets it instead. + return [self setHost:candidate forSurface:surface runtime:claim] ? candidate : nil; +} + +/// Whether any display has a lane for `surface`. Caller holds `_lock`. +- (BOOL)hasLaneLockedForSurface:(NSInteger)surface { + for (NSNumber *display in _lanesByDisplay) { + if (_lanesByDisplay[display][@(surface)] != nil) { + return YES; + } + } + return NO; } - (NSArray *)surfacesForDisplay:(NSInteger)display { @@ -306,6 +399,9 @@ - (void)dropSurfaceIfEmpty:(NSInteger)surface { [_lanesByDisplay[display] removeObjectForKey:@(surface)]; } } + if (![self hasLaneLockedForSurface:surface]) { + [_hostsBySurface removeObjectForKey:@(surface)]; + } [_lock unlock]; } 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 f0fd25a1..86b3c8bc 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactWindow.h +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactWindow.h @@ -1,5 +1,7 @@ #import +#import "BGSRNSecureRectangles.h" + NS_ASSUME_NONNULL_BEGIN /// Where the `vh` request's origin comes from: the one number that puts the @@ -50,23 +52,47 @@ FOUNDATION_EXPORT NSValue *_Nullable BGSRNReactRootOrigin(UIWindow *_Nullable ke NSArray *windows, BOOL (^isReactRoot)(UIView *view)); -/// How many views `BGSRNTaggedView` 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 a -/// pathological native hierarchy from costing the main thread on every pull. +/// 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 +/// a pathological native hierarchy from costing the main thread on a pull. FOUNDATION_EXPORT const NSUInteger BGSRNModalHostSearchBudget; -/// The first view whose `tag` is `tag` and that `matches` accepts, searching -/// each of `windows` in order, breadth-first, over at most `budget` views per -/// window. A view with that tag that `matches` rejects does not end the -/// search, in its window or any other: Fabric sets a component view's `tag` -/// to its React tag, but it does not own every `tag` in a window, so a native -/// view (or another runtime's) can carry the same small integer. Unlike -/// `-[UIView viewWithTag:]`, which returns the first view with the tag -/// whatever it is. nil when none. -FOUNDATION_EXPORT UIView *_Nullable BGSRNTaggedView(NSArray *windows, - NSInteger tag, - BOOL (^matches)(UIView *view), - NSUInteger budget); +/// The one view whose `tag` is `tag` and that `matches` accepts, searching +/// each of `windows` breadth-first over at most `budget` views per window. +/// nil when there is none, AND when there is more than one: Fabric sets a +/// component view's `tag` to its React tag, but tags are not a window-wide +/// namespace. A native view can carry the same small integer (a non-matching +/// view does not end the search, unlike `-[UIView viewWithTag:]`), and during +/// a reload two runtimes' hosts can share one tag, so two matches cannot say +/// which is current. +FOUNDATION_EXPORT UIView *_Nullable BGSRNUniqueTaggedView(NSArray *windows, + NSInteger tag, + BOOL (^matches)(UIView *view), + NSUInteger budget); + +/// 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 +/// (`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); + +/// The screen origin of `surface`, a `` lane in `store`. Read from the +/// current runtime's host (`hostForSurface:accepting:`, which takes a view +/// `isHost` accepts with that tag) when there is one, even when that gives no +/// origin yet. Otherwise, the registry lookup being unavailable or not +/// finding it, 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. +FOUNDATION_EXPORT NSValue *_Nullable BGSRNSecureSurfaceOrigin(BGSRNSecureRectangles *store, + NSInteger surface, + NSArray *windows, + BOOL (^isHost)(UIView *view)); 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 68612f69..ca5f039b 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSecureRectangles.h +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSecureRectangles.h @@ -64,9 +64,10 @@ FOUNDATION_EXPORT const int32_t BGSRNSecureFallbackDisplaySize; /// start the new module before the old one is invalidated, and Fabric numbers /// React tags from 1 again, so the old runtime's late writes would otherwise /// put back a lane the claim dropped, or clear the new runtime's lane on the -/// same key and uncover its Modal. Origins, display sizes and empty-lane drops -/// come from the wrapper's pull, which reads the live view tree and belongs to -/// no runtime. +/// same key and uncover its Modal. A Modal surface's host is named the same way +/// (`setHostResolver:forSurface:runtime:`), so the pull reads each origin +/// from the current runtime's host. Origins, display sizes and empty-lane drops are +/// written by the wrapper's pull, which belongs to no runtime. /// /// ## Threading /// @@ -75,6 +76,10 @@ FOUNDATION_EXPORT const int32_t BGSRNSecureFallbackDisplaySize; /// is explicit that a buffer rewritten by another thread while it reads is a /// use-after-free, so nothing here ever republishes into a buffer it has /// already handed out. +/// A module's lookup of a surface's host by its React tag (see +/// `setHostResolver:forSurface:runtime:`). +typedef id _Nullable (^BGSRNSecureHostResolver)(NSInteger surface); + @interface BGSRNSecureRectangles : NSObject /// The process-wide set of secured regions. @@ -144,6 +149,33 @@ FOUNDATION_EXPORT const int32_t BGSRNSecureFallbackDisplaySize; /// unless a newer runtime has already claimed the store. - (void)releaseRuntime:(NSInteger)claim; +/// Records `host`, held weakly, as the view whose origin places `surface`, a +/// `` lane of the module holding `claim`. The pull then reads the +/// origin from this host rather than searching the windows for a view with +/// the tag, which another runtime's host can share during a reload. Ignored +/// (NO) when the claim is stale, `surface` is the main one, or no display has +/// a lane for it. Every recorded host is forgotten on a claim and on a +/// release that drops lanes, and a surface's host goes with its last lane. +- (BOOL)setHost:(id)host forSurface:(NSInteger)surface runtime:(NSInteger)claim; + +/// Records how the module holding `claim` finds `surface`'s host: a lookup +/// in its own runtime's view registry. A Modal's host is mounted after JS has +/// measured and published inside it, so the lookup is kept and asked (on +/// main, by `hostForSurface:accepting:`) until it finds the host, which is +/// then recorded as by `setHost:forSurface:runtime:`. Same rules: ignored +/// (NO) when the claim is stale, `surface` is the main one, or it has no +/// lane; forgotten with the surface's last lane and on a claim. +- (BOOL)setHostResolver:(BGSRNSecureHostResolver)resolver + forSurface:(NSInteger)surface + runtime:(NSInteger)claim; + +/// `surface`'s host: the recorded one while it lives; otherwise what the +/// recorded lookup finds now, recorded if `accept` takes it and the claim it +/// was named with is still current. nil when there is neither, or the lookup +/// finds nothing acceptable. Calls the lookup outside the store's lock, on +/// the caller's thread: main, for a view registry. +- (nullable id)hostForSurface:(NSInteger)surface accepting:(BOOL (^)(id candidate))accept; + /// Forgets `surface` on every display where it holds no rectangles (its /// Modal is gone). A surface that still holds rectangles stays. - (void)dropSurfaceIfEmpty:(NSInteger)surface; diff --git a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactWindowTests.m b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactWindowTests.m index 066d47aa..8edc4b8c 100644 --- a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactWindowTests.m +++ b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactWindowTests.m @@ -13,6 +13,8 @@ @implementation BGSRNFakeReactRoot /// Stands in for `RCTModalHostViewComponentView`; the module's predicate /// matches the real class by name. @interface BGSRNFakeModalHost : UIView +/// What `RCTModalHostViewComponentView` presents its content in. +@property (nonatomic, strong, nullable) UIViewController *viewController; @end @implementation BGSRNFakeModalHost @@ -151,7 +153,7 @@ - (void)testANativeViewWithTheSameTagBeforeTheHostStillFindsTheHost { [container addSubview:host]; XCTAssertEqual([window viewWithTag:42], dummy, @"the fixture must put the dummy first"); - UIView *found = BGSRNTaggedView(@[ window ], 42, [self isModalHost], BGSRNModalHostSearchBudget); + UIView *found = BGSRNUniqueTaggedView(@[ window ], 42, [self isModalHost], BGSRNModalHostSearchBudget); XCTAssertEqual(found, host); } @@ -166,7 +168,7 @@ - (void)testAnAncestorWithTheSameTagStillFindsTheHost { host.tag = 7; [dummy addSubview:host]; - XCTAssertEqual(BGSRNTaggedView(@[ window ], 7, [self isModalHost], BGSRNModalHostSearchBudget), host); + XCTAssertEqual(BGSRNUniqueTaggedView(@[ window ], 7, [self isModalHost], BGSRNModalHostSearchBudget), host); } /// A wrong-class match in one window does not stop the search of the next. @@ -180,7 +182,7 @@ - (void)testAWrongClassMatchInOneWindowStillSearchesTheNext { host.tag = 42; [second addSubview:host]; - XCTAssertEqual(BGSRNTaggedView(@[ first, second ], 42, [self isModalHost], BGSRNModalHostSearchBudget), host); + XCTAssertEqual(BGSRNUniqueTaggedView(@[ first, second ], 42, [self isModalHost], BGSRNModalHostSearchBudget), host); } /// The class alone is not enough: another Modal's host has another tag. @@ -193,7 +195,7 @@ - (void)testAHostWithAnotherTagIsNotAMatch { dummy.tag = 42; [window addSubview:dummy]; - XCTAssertNil(BGSRNTaggedView(@[ window ], 42, [self isModalHost], BGSRNModalHostSearchBudget)); + XCTAssertNil(BGSRNUniqueTaggedView(@[ window ], 42, [self isModalHost], BGSRNModalHostSearchBudget)); } - (void)testTheTaggedSearchIsBoundedPerWindow { @@ -209,8 +211,206 @@ - (void)testTheTaggedSearchIsBoundedPerWindow { [parent addSubview:host]; // The window, five levels, then the host: the seventh view visited. - XCTAssertNil(BGSRNTaggedView(@[ window ], 42, [self isModalHost], 6)); - XCTAssertEqual(BGSRNTaggedView(@[ window ], 42, [self isModalHost], 7), host); + XCTAssertNil(BGSRNUniqueTaggedView(@[ window ], 42, [self isModalHost], 6)); + XCTAssertEqual(BGSRNUniqueTaggedView(@[ window ], 42, [self isModalHost], 7), host); +} + +#pragma mark - Which host places a lane (PR 48, round 2) + +/// A host with `tag`, presenting its content at `contentFrame` inside +/// `window` when `presentedIn` is non-nil (a stand-in for a presented view +/// controller: its view is in a window), not presented otherwise. +- (BGSRNFakeModalHost *)hostTagged:(NSInteger)tag + inWindow:(UIWindow *)window + presentedIn:(nullable UIWindow *)presentedIn + contentFrame:(CGRect)contentFrame { + BGSRNFakeModalHost *host = [BGSRNFakeModalHost new]; + host.tag = tag; + [window addSubview:host]; + UIViewController *controller = [UIViewController new]; + controller.view.frame = contentFrame; + if (presentedIn != nil) { + [presentedIn addSubview:controller.view]; + } + host.viewController = controller; + return host; +} + +- (BGSRNSecureRectangles *)storeWithLaneOn:(NSInteger)surface claim:(NSInteger *)claim { + BGSRNSecureRectangles *store = [[BGSRNSecureRectangles alloc] init]; + const NSInteger current = [store claimRuntime]; + const int32_t rects[] = {1, 2, 3, 4}; + XCTAssertTrue([store setCoordinates:rects count:4 forDisplay:0 surface:surface runtime:current]); + if (claim != NULL) { + *claim = current; + } + return store; +} + +- (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)]); +} + +- (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])); +} + +/// What a module's view registry answers: `host` for `tag`, nothing else. +- (BGSRNSecureHostResolver)registryWith:(UIView *)host calls:(NSUInteger *)calls { + __weak UIView *weakHost = host; + return ^id _Nullable(NSInteger tag) { + if (calls != NULL) { + *calls += 1; + } + return tag == weakHost.tag ? weakHost : nil; + }; +} + +/// Two hosts with tag 42, only the second presented and the current +/// runtime's: the origin comes from the second, though the walk meets the +/// first one first. +- (void)testTheOriginComesFromTheCurrentRuntimesHost { + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 390, 844)]; + [self hostTagged:42 inWindow:window presentedIn:nil contentFrame:CGRectMake(0, 300, 390, 544)]; + BGSRNFakeModalHost *current = [self hostTagged:42 inWindow:window presentedIn:window contentFrame:CGRectMake(0, 60, 390, 784)]; + NSInteger claim = 0; + 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]); + + XCTAssertEqualObjects(origin, [NSValue valueWithCGPoint:CGPointMake(0, 60)]); +} + +/// The host is mounted after the publish that names it: the lookup is asked +/// on each pull until it finds the host, which is then held, not looked up +/// again. +- (void)testTheLookupIsAskedUntilTheHostIsMountedThenTheHostIsHeld { + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 390, 844)]; + NSInteger claim = 0; + BGSRNSecureRectangles *store = [self storeWithLaneOn:42 claim:&claim]; + __block UIView *mounted = nil; + __block NSUInteger calls = 0; + [store setHostResolver:^id _Nullable(NSInteger tag) { + calls += 1; + return mounted; + } + forSurface:42 + runtime:claim]; + + XCTAssertNil(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost])); + XCTAssertEqual(calls, 1u); + + mounted = [self hostTagged:42 inWindow:window presentedIn:window contentFrame:CGRectMake(0, 60, 390, 784)]; + XCTAssertEqualObjects(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost]), + [NSValue valueWithCGPoint:CGPointMake(0, 60)]); + XCTAssertEqualObjects(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost]), + [NSValue valueWithCGPoint:CGPointMake(0, 60)]); + XCTAssertEqual(calls, 2u); +} + +/// What the registry answers must be a Modal host with the surface's tag. +- (void)testALookupAnswerThatIsNotAModalHostIsNotUsed { + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 390, 844)]; + UIView *plain = [UIView new]; + plain.tag = 42; + [window addSubview:plain]; + NSInteger claim = 0; + BGSRNSecureRectangles *store = [self storeWithLaneOn:42 claim:&claim]; + [store setHostResolver:^id _Nullable(NSInteger tag) { + return plain; + } + forSurface:42 + runtime:claim]; + + XCTAssertNil(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost])); + XCTAssertNil([store hostForSurface:42 accepting:^BOOL(id candidate) { return NO; }]); +} + +/// A reload with a sheet up: after the new runtime claims, the old host's +/// origin is never used, though it is still presented with the same tag. +- (void)testAfterAClaimTheOldHostsOriginIsNeverUsed { + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 390, 844)]; + BGSRNFakeModalHost *old = [self hostTagged:42 inWindow:window presentedIn:window contentFrame:CGRectMake(0, 300, 390, 544)]; + 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]), + [NSValue valueWithCGPoint:CGPointMake(0, 300)]); + + const NSInteger current = [store claimRuntime]; + XCTAssertNil([store hostForSurface:42 accepting:^BOOL(id candidate) { return YES; }], + @"a claim forgets every host and lookup"); + const int32_t rects[] = {1, 2, 3, 4}; + [store setCoordinates:rects count:4 forDisplay:0 surface:42 runtime:current]; + 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])); + // 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])); + // 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]), + [NSValue valueWithCGPoint:CGPointZero]); +} + +/// The fallback walk cannot tell two hosts with one tag apart: no origin, +/// even when only one is presented. +- (void)testTwoWalkMatchesGiveNoOrigin { + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 390, 844)]; + [self hostTagged:42 inWindow:window presentedIn:window contentFrame:CGRectMake(0, 300, 390, 544)]; + UIWindow *other = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 390, 844)]; + [self hostTagged:42 inWindow:other presentedIn:nil contentFrame:CGRectMake(0, 60, 390, 784)]; + 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]), + [NSValue valueWithCGPoint:CGPointMake(0, 300)]); +} + +/// The one walk match whose content has no window gives no origin. +- (void)testAWalkMatchThatIsNotPresentedGivesNoOrigin { + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 390, 844)]; + [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])); +} + +/// A held host that has gone is looked up again; the lookup not finding it +/// falls back to the unique class+tag match. +- (void)testAGoneHostTheLookupCannotFindFallsBackToTheUniqueWalk { + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 390, 844)]; + [self hostTagged:42 inWindow:window presentedIn:window contentFrame:CGRectMake(0, 300, 390, 544)]; + NSInteger claim = 0; + BGSRNSecureRectangles *store = [self storeWithLaneOn:42 claim:&claim]; + __block NSUInteger calls = 0; + @autoreleasepool { + BGSRNFakeModalHost *gone = [BGSRNFakeModalHost new]; + gone.tag = 42; + XCTAssertTrue([store setHost:gone forSurface:42 runtime:claim]); + [store setHostResolver:^id _Nullable(NSInteger tag) { + calls += 1; + return nil; + } + forSurface:42 + runtime:claim]; + } + + XCTAssertEqualObjects(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost]), + [NSValue valueWithCGPoint:CGPointMake(0, 300)]); + XCTAssertEqual(calls, 1u); } @end diff --git a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m index fe2bd7ab..018e7778 100644 --- a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m +++ b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNSecureRectanglesTests.m @@ -464,6 +464,100 @@ - (void)testTheCurrentRuntimesMalformedWriteIsStillRejected { XCTAssertEqualObjects([_store surfacesForDisplay:0], @[]); } +#pragma mark - A Modal surface's host (PR 48, round 2) + +static BOOL (^const BGSRNAcceptAny)(id) = ^BOOL(id candidate) { + return YES; +}; + +- (void)testAHostIsRecordedOnlyForTheCurrentRuntimesModalLane { + NSObject *host = [NSObject new]; + const int32_t rects[] = {1, 2, 3, 4}; + const NSInteger old = [_store claimRuntime]; + XCTAssertFalse([_store setHost:host forSurface:42 runtime:old], @"no lane for 42 yet"); + [_store setCoordinates:rects count:4 forDisplay:0 surface:42 runtime:old]; + XCTAssertFalse([_store setHost:host forSurface:BGSRNSecureMainSurface runtime:old]); + XCTAssertTrue([_store setHost:host forSurface:42 runtime:old]); + XCTAssertEqual([_store hostForSurface:42 accepting:BGSRNAcceptAny], host); + + const NSInteger current = [_store claimRuntime]; + XCTAssertNil([_store hostForSurface:42 accepting:BGSRNAcceptAny]); + [_store setCoordinates:rects count:4 forDisplay:0 surface:42 runtime:current]; + XCTAssertFalse([_store setHost:host forSurface:42 runtime:old]); + XCTAssertNil([_store hostForSurface:42 accepting:BGSRNAcceptAny]); + XCTAssertTrue([_store setHost:host forSurface:42 runtime:current]); + XCTAssertEqual([_store hostForSurface:42 accepting:BGSRNAcceptAny], host); +} + +- (void)testALookupIsNamedOnlyByTheCurrentRuntime { + NSObject *host = [NSObject new]; + const int32_t rects[] = {1, 2, 3, 4}; + BGSRNSecureHostResolver lookup = ^id _Nullable(NSInteger tag) { + return host; + }; + const NSInteger old = [_store claimRuntime]; + XCTAssertFalse([_store setHostResolver:lookup forSurface:42 runtime:old], @"no lane for 42 yet"); + [_store setCoordinates:rects count:4 forDisplay:0 surface:42 runtime:old]; + [_store claimRuntime]; + const NSInteger current = [_store claimRuntime]; + [_store setCoordinates:rects count:4 forDisplay:0 surface:42 runtime:current]; + + XCTAssertFalse([_store setHostResolver:lookup forSurface:42 runtime:old]); + XCTAssertNil([_store hostForSurface:42 accepting:BGSRNAcceptAny]); + XCTAssertTrue([_store setHostResolver:lookup forSurface:42 runtime:current]); + XCTAssertEqual([_store hostForSurface:42 accepting:BGSRNAcceptAny], host); +} + +/// A claim that lands while a lookup is running: what it found is not kept. +- (void)testAHostFoundAcrossANewClaimIsNotKept { + NSObject *host = [NSObject new]; + const int32_t rects[] = {1, 2, 3, 4}; + const NSInteger old = [_store claimRuntime]; + [_store setCoordinates:rects count:4 forDisplay:0 surface:42 runtime:old]; + BGSRNSecureRectangles *store = _store; + [_store setHostResolver:^id _Nullable(NSInteger tag) { + const NSInteger current = [store claimRuntime]; + const int32_t again[] = {1, 2, 3, 4}; + [store setCoordinates:again count:4 forDisplay:0 surface:42 runtime:current]; + return host; + } + forSurface:42 + runtime:old]; + + XCTAssertNil([_store hostForSurface:42 accepting:BGSRNAcceptAny]); + XCTAssertNil([_store hostForSurface:42 accepting:BGSRNAcceptAny]); +} + +- (void)testASurfacesHostGoesWithItsLastLane { + NSObject *host = [NSObject new]; + const int32_t rects[] = {1, 2, 3, 4}; + const NSInteger claim = [_store claimRuntime]; + [_store setCoordinates:rects count:4 forDisplay:0 surface:42 runtime:claim]; + [_store setHost:host forSurface:42 runtime:claim]; + [_store setHostResolver:^id _Nullable(NSInteger tag) { + return host; + } + forSurface:42 + runtime:claim]; + + [_store setCoordinates:NULL count:0 forDisplay:0 surface:42 runtime:claim]; + [_store dropSurfaceIfEmpty:42]; + + XCTAssertNil([_store hostForSurface:42 accepting:BGSRNAcceptAny]); +} + +- (void)testReleasingTheCurrentRuntimeForgetsItsHosts { + NSObject *host = [NSObject new]; + const int32_t rects[] = {1, 2, 3, 4}; + const NSInteger claim = [_store claimRuntime]; + [_store setCoordinates:rects count:4 forDisplay:0 surface:42 runtime:claim]; + [_store setHost:host forSurface:42 runtime:claim]; + + [_store releaseRuntime:claim]; + + XCTAssertNil([_store hostForSurface:42 accepting:BGSRNAcceptAny]); +} + /// The shared store outlives any one wrapper: the init provider registers one /// before launch and setWrapperInfo swaps in another, and the regions the app /// marked secret must survive that. From a72596f486c58fefd36107f1e6aa0c843ac178d9 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Mon, 5 Oct 2026 18:41:31 +0500 Subject: [PATCH 19/19] fix(privacy,ios): a named Modal host that is not found yet fails closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabric mounts a Modal's host after the publish that names this runtime's lookup, so the lookup finds nothing for the first pulls of every new Modal. The pull then fell back to the class+tag walk, and during a reload the old runtime's still-presented host can be its only match: for about a second its origin placed the new runtime's rectangles. Once the current runtime has named a host or lookup for a surface (isHostNamedForSurface:), a lookup that finds nothing means the origin is unknown: the lane stays fail-closed, served as the whole display, until the lookup finds this runtime's host. The walk runs only when nothing was ever named, such as without a view registry. Addresses PR 48 review thread 4184201590 (round 2 review, issue 1). ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --- packages/react-native/ios/BugseeModule.mm | 9 ++-- .../BugseeRNSupport/BGSRNReactWindow.m | 7 +++ .../BugseeRNSupport/BGSRNSecureRectangles.m | 7 +++ .../include/BGSRNReactWindow.h | 6 ++- .../include/BGSRNSecureRectangles.h | 7 +++ .../BGSRNReactWindowTests.m | 54 +++++++++++++++++-- 6 files changed, 81 insertions(+), 9 deletions(-) diff --git a/packages/react-native/ios/BugseeModule.mm b/packages/react-native/ios/BugseeModule.mm index 05c8cd5b..aad3e5d1 100644 --- a/packages/react-native/ios/BugseeModule.mm +++ b/packages/react-native/ios/BugseeModule.mm @@ -279,8 +279,8 @@ static Class _Nullable BGSRNModalHostClass(void) { /// time (`nameModalHostForSurface:`), the same shape as Android's /// `watchSurface`: a window can hold another runtime's host with the same /// tag during a reload, and only the module's own view registry tells them -/// apart. Without a recorded host, the one class+tag match in the windows -/// the SDK walks. nil (none or several, or off main) leaves the lane's +/// apart. While that host has not mounted, unknown. Only without a +/// registry, the one class+tag match in the windows the SDK walks. nil (none or several, or off main) leaves the lane's /// origin as it was: unknown for a new lane, which is served as the whole /// display. static NSValue *_Nullable BGSRNModalSurfaceOrigin(NSInteger surface) { @@ -1076,8 +1076,9 @@ - (void)setSecureRectanglesOnSurface:(double)display /// with React tag `surface`: this runtime's view registry, which another /// runtime's same-tag host is not in. Not looked up here: Fabric mounts the /// host after JS has measured and published inside it, so the pull asks (on -/// main) until the host is there, then holds it weakly. No registry: nothing -/// is named, and the pull falls back to a unique class+tag match. +/// main) until the host is there, then holds it weakly; until then the lane +/// fails closed. No registry: nothing is named, and the pull falls back to a +/// unique class+tag match. - (void)nameModalHostForSurface:(NSInteger)surface { __weak RCTViewRegistry *registry = self.viewRegistry_DEPRECATED; if (registry == nil) { diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactWindow.m b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactWindow.m index 35be74fb..61866813 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactWindow.m +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNReactWindow.m @@ -162,6 +162,13 @@ - (UIViewController *)viewController; // search that could find another runtime's. return BGSRNModalHostOrigin(host); } + if ([store isHostNamedForSurface:surface]) { + // Named but not found: the current runtime's host has not mounted yet + // (Fabric mounts it after the publish), or has gone. Unknown, so the lane + // fails closed: during a reload the walk's one match can be the old + // runtime's still-presented host. + return nil; + } UIView *found = BGSRNUniqueTaggedView(windows, surface, isHost, BGSRNModalHostSearchBudget); return found == nil ? nil : BGSRNModalHostOrigin(found); } diff --git a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m index 4e9dde71..570385f5 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/BGSRNSecureRectangles.m @@ -323,6 +323,13 @@ - (BOOL)setHostResolver:(BGSRNSecureHostResolver)resolver forSurface:(NSInteger) return entry != nil; } +- (BOOL)isHostNamedForSurface:(NSInteger)surface { + [_lock lock]; + const BOOL named = _hostsBySurface[@(surface)] != nil; + [_lock unlock]; + return named; +} + /// `surface`'s host entry for the module holding `claim`, made if needed; /// nil when the claim is stale, `surface` is the main one, or no display has /// a lane for it. Caller holds `_lock`. 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 86b3c8bc..dd3f8237 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactWindow.h +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNReactWindow.h @@ -85,8 +85,10 @@ FOUNDATION_EXPORT NSValue *_Nullable BGSRNModalHostOrigin(UIView *host); /// The screen origin of `surface`, a `` lane in `store`. Read from the /// current runtime's host (`hostForSurface:accepting:`, which takes a view /// `isHost` accepts with that tag) when there is one, even when that gives no -/// origin yet. Otherwise, the registry lookup being unavailable or not -/// finding it, the one view `isHost` accepts with that tag in `windows` +/// origin yet. nil while a host or lookup the current runtime named finds +/// nothing (`isHostNamedForSurface:`): the host mounts after the publish, and +/// until then the origin is unknown. Only when nothing was ever named (no +/// 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. 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 ca5f039b..292b790a 100644 --- a/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSecureRectangles.h +++ b/packages/react-native/ios/Support/Sources/BugseeRNSupport/include/BGSRNSecureRectangles.h @@ -176,6 +176,13 @@ typedef id _Nullable (^BGSRNSecureHostResolver)(NSInteger surface); /// the caller's thread: main, for a view registry. - (nullable id)hostForSurface:(NSInteger)surface accepting:(BOOL (^)(id candidate))accept; +/// Whether the current runtime has named `surface`'s host or its lookup +/// (`setHost:...`, `setHostResolver:...`). Once it has, only that host places +/// the lane: a lookup that finds nothing yet means the origin is unknown, not +/// that the windows may be searched, where another runtime's host can be the +/// only one with the tag. +- (BOOL)isHostNamedForSurface:(NSInteger)surface; + /// Forgets `surface` on every display where it holds no rectangles (its /// Modal is gone). A surface that still holds rectangles stays. - (void)dropSurfaceIfEmpty:(NSInteger)surface; diff --git a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactWindowTests.m b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactWindowTests.m index 8edc4b8c..07501a74 100644 --- a/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactWindowTests.m +++ b/packages/react-native/ios/Support/Tests/BugseeRNSupportTests/BGSRNReactWindowTests.m @@ -389,8 +389,8 @@ - (void)testAWalkMatchThatIsNotPresentedGivesNoOrigin { } /// A held host that has gone is looked up again; the lookup not finding it -/// falls back to the unique class+tag match. -- (void)testAGoneHostTheLookupCannotFindFallsBackToTheUniqueWalk { +/// leaves the origin unknown, not a search that could find another host. +- (void)testAGoneHostTheLookupCannotFindGivesNoOrigin { UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 390, 844)]; [self hostTagged:42 inWindow:window presentedIn:window contentFrame:CGRectMake(0, 300, 390, 544)]; NSInteger claim = 0; @@ -408,9 +408,57 @@ - (void)testAGoneHostTheLookupCannotFindFallsBackToTheUniqueWalk { runtime:claim]; } + XCTAssertNil(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost])); + XCTAssertEqual(calls, 1u); +} + +/// A reload with a sheet up, before the new runtime's Modal has mounted: the +/// old runtime's presented host is the only one with tag 42, and the new +/// runtime has named its lookup, which finds nothing yet. The old host's +/// origin must not place the new lane: it stays unknown and is served as the +/// whole display. +- (void)testAnOldHostAloneWhileTheNewHostIsNotMountedGivesNoOrigin { + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 390, 844)]; + [self hostTagged:42 inWindow:window presentedIn:window contentFrame:CGRectMake(0, 300, 390, 544)]; + BGSRNSecureRectangles *store = [[BGSRNSecureRectangles alloc] init]; + [store setDisplaySize:CGSizeMake(390, 844) forDisplay:0]; + [store setOrigin:CGPointZero forDisplay:0]; + [store claimRuntime]; + const NSInteger current = [store claimRuntime]; + const int32_t rects[] = {10, 20, 110, 120}; + XCTAssertTrue([store setCoordinates:rects count:4 forDisplay:0 surface:42 runtime:current]); + XCTAssertTrue([store setHostResolver:^id _Nullable(NSInteger tag) { + return nil; + } + forSurface:42 + runtime:current]); + 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]); + + XCTAssertNil(origin); + // What the pull then serves: the lane's origin is still unknown. + NSData *snapshot = [store snapshotForDisplay:0]; + int32_t served[6] = {0}; + [snapshot getBytes:served length:sizeof(served)]; + XCTAssertEqual(CFSwapInt32LittleToHost((uint32_t)served[1]), 1u); + XCTAssertEqual((int32_t)CFSwapInt32LittleToHost((uint32_t)served[2]), 0); + XCTAssertEqual((int32_t)CFSwapInt32LittleToHost((uint32_t)served[3]), 0); + XCTAssertEqual((int32_t)CFSwapInt32LittleToHost((uint32_t)served[4]), 390); + XCTAssertEqual((int32_t)CFSwapInt32LittleToHost((uint32_t)served[5]), 844); +} + +/// With no lookup ever named (no registry), the walk's one presented match +/// places the lane. +- (void)testWithNoLookupNamedTheUniqueWalkPlacesTheLane { + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 390, 844)]; + [self hostTagged:42 inWindow:window presentedIn:window contentFrame:CGRectMake(0, 300, 390, 544)]; + BGSRNSecureRectangles *store = [self storeWithLaneOn:42 claim:NULL]; + + XCTAssertFalse([store isHostNamedForSurface:42]); XCTAssertEqualObjects(BGSRNSecureSurfaceOrigin(store, 42, @[ window ], [self isModalHost]), [NSValue valueWithCGPoint:CGPointMake(0, 300)]); - XCTAssertEqual(calls, 1u); } @end