clintplay-react-native 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,11 @@
11
11
  #import <React/RCTBridgeModule.h>
12
12
  #import <UIKit/UIKit.h>
13
13
 
14
+ @interface UIView (ClintplayNativeID)
15
+ - (NSString *)nativeID;
16
+ - (NSString *)nativeId;
17
+ @end
18
+
14
19
  static UIWindow *FindKeyWindow(void) {
15
20
  if (@available(iOS 13.0, *)) {
16
21
  for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) {
@@ -121,6 +126,73 @@ static NSString *HexColorStringFromUIColor(UIColor *color) {
121
126
  return nil;
122
127
  }
123
128
 
129
+ // The React Native <Text> is not a UILabel: it is RCTParagraphComponentView on the New
130
+ // Architecture and RCTTextView on the legacy one. Both keep the rendered string with its
131
+ // attributes; reading it here gives the replay the visible text plus size, weight, colour
132
+ // and alignment (accessibilityLabel carries only the string, and an app-provided label
133
+ // replaces it).
134
+ static NSAttributedString *ReactNativeAttributedText(UIView *view) {
135
+ NSString *className = NSStringFromClass([view class]);
136
+ id value = nil;
137
+ @try {
138
+ if ([className isEqualToString:@"RCTParagraphComponentView"] &&
139
+ [view respondsToSelector:@selector(attributedText)]) {
140
+ value = [(id)view attributedText];
141
+ } else if ([className isEqualToString:@"RCTTextView"]) {
142
+ // Legacy architecture: no public getter, the storage lives in the `_textStorage` ivar.
143
+ value = [view valueForKey:@"textStorage"];
144
+ }
145
+ } @catch (NSException *e) {
146
+ value = nil;
147
+ }
148
+ return [value isKindOfClass:[NSAttributedString class]] ? (NSAttributedString *)value : nil;
149
+ }
150
+
151
+ // CSS-style weight (100-900) from the font's UIFontWeightTrait (-1.0 to 1.0).
152
+ static NSInteger CSSFontWeight(UIFont *font) {
153
+ NSDictionary *traits = [font.fontDescriptor objectForKey:UIFontDescriptorTraitsAttribute];
154
+ NSNumber *weight = [traits isKindOfClass:[NSDictionary class]] ? traits[UIFontWeightTrait] : nil;
155
+ if (![weight isKindOfClass:[NSNumber class]]) {
156
+ return (font.fontDescriptor.symbolicTraits & UIFontDescriptorTraitBold) ? 700 : 400;
157
+ }
158
+ CGFloat w = weight.doubleValue;
159
+ if (w <= UIFontWeightLight) return 300;
160
+ if (w < UIFontWeightMedium) return 400;
161
+ if (w < UIFontWeightSemibold) return 500;
162
+ if (w < UIFontWeightBold) return 600;
163
+ return 700;
164
+ }
165
+
166
+ static void PutTextStyle(NSMutableDictionary *nodeDict, UIFont *font, UIColor *color, NSTextAlignment alignment) {
167
+ @try {
168
+ if ([font isKindOfClass:[UIFont class]] && font.pointSize > 0) {
169
+ nodeDict[@"fontSize"] = @(round(font.pointSize * 10.0) / 10.0);
170
+ nodeDict[@"fontWeight"] = @(CSSFontWeight(font));
171
+ }
172
+ NSString *hex = [color isKindOfClass:[UIColor class]] ? HexColorStringFromUIColor(color) : nil;
173
+ if (hex) {
174
+ nodeDict[@"color"] = hex;
175
+ }
176
+ if (alignment == NSTextAlignmentCenter) {
177
+ nodeDict[@"textAlign"] = @"center";
178
+ } else if (alignment == NSTextAlignmentRight) {
179
+ nodeDict[@"textAlign"] = @"right";
180
+ } else {
181
+ nodeDict[@"textAlign"] = @"left";
182
+ }
183
+ } @catch (NSException *e) {
184
+ // Style is best effort: the text alone still makes the replay readable.
185
+ }
186
+ }
187
+
188
+ static void PutAttributedTextStyle(NSMutableDictionary *nodeDict, NSAttributedString *text) {
189
+ if (text.length == 0) return;
190
+ NSDictionary *attrs = [text attributesAtIndex:0 effectiveRange:NULL];
191
+ NSParagraphStyle *paragraph = attrs[NSParagraphStyleAttributeName];
192
+ NSTextAlignment alignment = [paragraph isKindOfClass:[NSParagraphStyle class]] ? paragraph.alignment : NSTextAlignmentNatural;
193
+ PutTextStyle(nodeDict, attrs[NSFontAttributeName], attrs[NSForegroundColorAttributeName], alignment);
194
+ }
195
+
124
196
  static NSDictionary *SerializeView(UIView *view, NSInteger *nodeCounter, NSInteger depth) {
125
197
  if (depth > 64) {
126
198
  return nil;
@@ -218,21 +290,42 @@ static NSDictionary *SerializeView(UIView *view, NSInteger *nodeCounter, NSInteg
218
290
  if (identifier.length > 0) {
219
291
  nodeDict[@"accessibilityIdentifier"] = identifier;
220
292
  }
221
- if (view.accessibilityLabel.length > 0) {
222
- nodeDict[@"accessibilityLabel"] = isMasked ? @"***" : view.accessibilityLabel;
293
+ // Reuse the label read above: on RCTParagraphComponentView every read rebuilds the
294
+ // string from the shadow tree state.
295
+ if (accLabel.length > 0) {
296
+ nodeDict[@"accessibilityLabel"] = isMasked ? @"***" : accLabel;
223
297
  }
224
298
 
225
- // Text extraction from UILabel, UITextField, UITextView, UIButton
299
+ // Text extraction from the React Native <Text>, UILabel, UITextField, UITextView, UIButton.
300
+ // Style is read later, only for unmasked nodes with text: reading a UIButton's titleLabel
301
+ // creates one on image-only buttons, and a passive recorder must not change the host UI.
226
302
  NSString *extractedText = nil;
227
- if ([view isKindOfClass:[UILabel class]]) {
303
+ void (^putStyle)(void) = nil;
304
+ NSAttributedString *reactNativeText = ReactNativeAttributedText(view);
305
+ if (reactNativeText) {
306
+ // Copy: on the legacy architecture this is the live backing store of the NSTextStorage.
307
+ extractedText = [reactNativeText.string copy];
308
+ putStyle = ^{
309
+ PutAttributedTextStyle(nodeDict, reactNativeText);
310
+ };
311
+ } else if ([view isKindOfClass:[UILabel class]]) {
228
312
  UILabel *label = (UILabel *)view;
229
313
  extractedText = label.text ?: (label.attributedText ? label.attributedText.string : nil);
314
+ putStyle = ^{
315
+ PutTextStyle(nodeDict, label.font, label.textColor, label.textAlignment);
316
+ };
230
317
  } else if ([view isKindOfClass:[UITextField class]]) {
231
318
  UITextField *textField = (UITextField *)view;
232
319
  extractedText = textField.text ?: (textField.attributedText ? textField.attributedText.string : nil);
320
+ putStyle = ^{
321
+ PutTextStyle(nodeDict, textField.font, textField.textColor, textField.textAlignment);
322
+ };
233
323
  } else if ([view isKindOfClass:[UITextView class]]) {
234
324
  UITextView *textView = (UITextView *)view;
235
325
  extractedText = textView.text ?: (textView.attributedText ? textView.attributedText.string : nil);
326
+ putStyle = ^{
327
+ PutTextStyle(nodeDict, textView.font, textView.textColor, textView.textAlignment);
328
+ };
236
329
  } else if ([view isKindOfClass:[UIButton class]]) {
237
330
  UIButton *button = (UIButton *)view;
238
331
  if (@available(iOS 15.0, *)) {
@@ -243,10 +336,21 @@ static NSDictionary *SerializeView(UIView *view, NSInteger *nodeCounter, NSInteg
243
336
  if (!extractedText) {
244
337
  extractedText = [button titleForState:UIControlStateNormal] ?: (button.currentTitle ?: (button.currentAttributedTitle ? button.currentAttributedTitle.string : nil));
245
338
  }
339
+ putStyle = ^{
340
+ PutTextStyle(nodeDict, button.titleLabel.font, button.currentTitleColor, NSTextAlignmentCenter);
341
+ };
246
342
  }
247
343
 
248
344
  if (extractedText) {
249
345
  nodeDict[@"text"] = isMasked ? @"***" : extractedText;
346
+ if (!isMasked && extractedText.length > 0 && putStyle) {
347
+ putStyle();
348
+ }
349
+ // The text view's accessibilityLabel defaults to its own string: sending both doubles
350
+ // the payload of every <Text> for nothing.
351
+ if (!isMasked && [nodeDict[@"accessibilityLabel"] isEqual:extractedText]) {
352
+ [nodeDict removeObjectForKey:@"accessibilityLabel"];
353
+ }
250
354
  }
251
355
 
252
356
  if (isMasked) {
@@ -278,7 +382,7 @@ static NSDictionary *SerializeView(UIView *view, NSInteger *nodeCounter, NSInteg
278
382
  RCT_EXPORT_MODULE(ClintplayRN)
279
383
 
280
384
  RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(getVersion) {
281
- return @"0.1.0";
385
+ return @"0.2.1";
282
386
  }
283
387
 
284
388
  - (NSDictionary *)extractHierarchyDictionaryOnMainThread {
@@ -314,7 +418,9 @@ RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(getVersion) {
314
418
  return @"{\"version\":1,\"nodes\":[]}";
315
419
  }
316
420
  NSError *error = nil;
317
- NSData *jsonData = [NSJSONSerialization dataWithJSONObject:payload options:0 error:&error];
421
+ // Sorted keys keep an unchanged screen byte-identical between captures, which is what
422
+ // lets the JS recorder skip re-sending it.
423
+ NSData *jsonData = [NSJSONSerialization dataWithJSONObject:payload options:NSJSONWritingSortedKeys error:&error];
318
424
  if (error || !jsonData) {
319
425
  return @"{\"version\":1,\"nodes\":[]}";
320
426
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clintplay-react-native",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Clintplay React Native SDK — session capture for iOS and Android apps. Companion to clintplay-sdk (web).",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",