@obsrviq/react-native 0.3.1 → 0.3.2
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.
- package/README.md +48 -19
- package/android/build.gradle +1 -1
- package/android/src/main/java/app/obsrviq/replay/ObsrviqMaskViewManager.kt +42 -0
- package/android/src/main/java/app/{lumera/replay/LumeraReplayModule.kt → obsrviq/replay/ObsrviqReplayModule.kt} +13 -13
- package/android/src/main/java/app/{lumera/replay/LumeraReplayPackage.kt → obsrviq/replay/ObsrviqReplayPackage.kt} +11 -11
- package/ios/{LumeraMaskView.h → ObsrviqMaskView.h} +2 -2
- package/ios/{LumeraMaskView.m → ObsrviqMaskView.m} +14 -14
- package/ios/{LumeraMaskViewManager.m → ObsrviqMaskViewManager.m} +7 -7
- package/ios/{LumeraReplay.swift → ObsrviqReplay.swift} +11 -11
- package/ios/ObsrviqReplayModule.h +20 -0
- package/ios/{LumeraReplayModule.mm → ObsrviqReplayModule.mm} +26 -26
- package/{lumera-react-native.podspec → obsrviq-react-native.podspec} +5 -5
- package/package.json +5 -5
- package/src/ObsrviqMask.tsx +63 -0
- package/src/config.ts +8 -5
- package/src/index.ts +18 -12
- package/src/session.ts +17 -6
- package/src/spec/{NativeLumeraReplay.ts → NativeObsrviqReplay.ts} +6 -2
- package/src/transport.ts +2 -1
- package/android/src/main/java/app/lumera/replay/LumeraMaskViewManager.kt +0 -39
- package/ios/LumeraReplayModule.h +0 -20
- package/src/LumeraMask.tsx +0 -52
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @obsrviq/react-native
|
|
2
2
|
|
|
3
|
-
Mobile session replay + analytics capture for **React Native (iOS + Android)**, feeding the same
|
|
3
|
+
Mobile session replay + analytics capture for **React Native (iOS + Android)**, feeding the same Obsrviq backend as the web tracker. Engineered around one non-negotiable: **zero added latency on the device.**
|
|
4
4
|
|
|
5
|
-
> **Status:
|
|
5
|
+
> **Status: shipped.** The full pipeline is live — the pure-JS layer (public API, session/identity, network/error/console capture, transport), the native screenshot-capture TurboModule (iOS Swift / Android Kotlin), native touch capture, the backend frame-storage path, and the mobile player renderer (screenshot timeline + touch ripples + network waterfall). Native code is compiled inside the consuming app's iOS/Android build — see [Building](#building-native).
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -59,29 +59,55 @@ Requires React Native ≥ 0.76 (New Architecture). Works under the old architect
|
|
|
59
59
|
## Quickstart
|
|
60
60
|
|
|
61
61
|
```ts
|
|
62
|
-
import {
|
|
62
|
+
import { ObsrviqReplay } from '@obsrviq/react-native';
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
ObsrviqReplay.init({
|
|
65
65
|
siteKey: 'pk_live_…',
|
|
66
|
-
// privacy-by-default: all text + images masked. Opt out per-view with <
|
|
66
|
+
// privacy-by-default: all text + images masked. Opt out per-view with <ObsrviqMask unmask>.
|
|
67
67
|
});
|
|
68
68
|
|
|
69
69
|
// later — identical API to the web SDK:
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
ObsrviqReplay.identify('user_123', { plan: 'pro' });
|
|
71
|
+
ObsrviqReplay.track('checkout_started', { cart: 3 });
|
|
72
|
+
ObsrviqReplay.conversion('purchase', { value: 49.0 });
|
|
73
|
+
ObsrviqReplay.tag('vip', 'beta');
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
+
> **Naming:** the SDK is `ObsrviqReplay` / `ObsrviqConfig` / `<ObsrviqMask>`. The old
|
|
77
|
+
> `LumeraReplay` / `LumeraConfig` / `<LumeraMask>` names are still exported as **deprecated
|
|
78
|
+
> aliases**, so pre-rebrand integrations keep working unchanged.
|
|
79
|
+
|
|
76
80
|
## API
|
|
77
81
|
|
|
78
|
-
Mirrors `@obsrviq/tracker` 1:1 — `init`, `identify`, `setMetadata`, `track`, `conversion`, `tag`, `startTask`/`endTask`, `setConsent`, `reset`, `stop`, `flush`, `getDiagnostics`, `sessionId`.
|
|
82
|
+
Mirrors `@obsrviq/tracker` 1:1 — `init`, `identify`, `setMetadata`, `track`, `conversion`, `tag`, `startTask`/`endTask`, `setConsent`, `reset`, `stop`, `flush`, `getDiagnostics`, `sessionId`. `screen(name)` records a route view (the mobile analog of a web pageview — call it from your navigation listener).
|
|
83
|
+
|
|
84
|
+
## Config
|
|
85
|
+
|
|
86
|
+
Passed to `init({ siteKey, … })`. See [`src/config.ts`](src/config.ts) for the authoritative list.
|
|
87
|
+
|
|
88
|
+
| Option | Default | What it does |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| `siteKey` | — | **Required.** Your ingest key (`pk_…`). |
|
|
91
|
+
| `enableReplay` | `true` | Record the screen (native screenshot stream). |
|
|
92
|
+
| `fps` | `1` | Capture cadence cap, frames/sec (change-driven underneath). |
|
|
93
|
+
| `jpegQuality` | `0.4` | JPEG quality 0..1 on the encode path. |
|
|
94
|
+
| `maxCaptureDim` | `1200` | Cap the longer edge of each frame (px) — the storage/bandwidth lever. `0` = full native res. |
|
|
95
|
+
| `maskAllText` | `true` | Mask all text before any pixel is persisted. |
|
|
96
|
+
| `maskAllImages` | `true` | Mask all images/media. |
|
|
97
|
+
| `captureTouches` | `true` | Capture taps + swipes as a touch overlay (native, non-blocking gesture observer — never interferes with the app's own gestures). |
|
|
98
|
+
| `captureNetworkBodies` | `false` | Capture request + response bodies (masked, size-capped at 8KB). Off by default — bodies can hold PII. |
|
|
99
|
+
| `redactHeaders` | `['authorization','cookie','set-cookie']` | Header allowlist redaction for captured network events. |
|
|
100
|
+
| `captureConsole` | `true` | Mirror `console.*` into the session. |
|
|
101
|
+
| `sampleRate` | `1` | Fraction of sessions recorded (0..1). |
|
|
102
|
+
| `continueSession` | `false` | Resume the same session across foreground/background within the window. |
|
|
103
|
+
| `requireConsent` | `false` | Gate all recording until `setConsent(true)`. |
|
|
79
104
|
|
|
80
105
|
## Privacy / masking
|
|
81
106
|
|
|
82
107
|
- **Default:** all text and images are masked before any pixel is persisted (masking runs on the capture thread, pre-encode).
|
|
83
|
-
- **Per-view control:** `<
|
|
84
|
-
- Network header redaction (`authorization`/`cookie`/`set-cookie` by default)
|
|
108
|
+
- **Per-view control:** `<ObsrviqMask>` / `<ObsrviqMask unmask>` wrappers (call `setViewMasked(reactTag, …)` natively).
|
|
109
|
+
- Network: header allowlist redaction (`authorization`/`cookie`/`set-cookie` by default). Request/response **bodies are off by default** — opt in with `captureNetworkBodies: true` (bodies are size-capped at 8KB and can hold personal data, so enable deliberately).
|
|
110
|
+
- Touch capture records only coordinates + phase (start/move/end) — never the content under the finger.
|
|
85
111
|
|
|
86
112
|
## Data flow
|
|
87
113
|
|
|
@@ -91,18 +117,21 @@ Two independent uploaders, one session:
|
|
|
91
117
|
|---|---|---|---|---|
|
|
92
118
|
| Structured (network/error/console/custom) | JS | `POST /v1/batch` | `network`/`error`/`console`/`custom` | Postgres `events` |
|
|
93
119
|
| Screenshot frames | **Native** | `POST /v1/batch` | `screen` | blob + `mobile_frames` index |
|
|
120
|
+
| Touch (taps/swipes) | **Native** | `POST /v1/batch` | `touch` | Postgres `events` (synced to the timeline by `t`) |
|
|
94
121
|
|
|
95
|
-
|
|
122
|
+
All three share `siteKey` + `sessionId`. No `seq` collision: structured events never write replay chunks; `screen` frames get their own frame index; `touch` events are small and land in the `events` table. The worker routes `screen` to the blob store + `mobile_frames`, keeps everything else (including `touch`) in `events`. The player overlays `touch` events as ripples and renders `network` events as a per-request waterfall with an expandable headers/body inspector.
|
|
96
123
|
|
|
97
124
|
## Roadmap
|
|
98
125
|
|
|
99
126
|
- [x] JS core — public API, session/identity, network/error/console capture, transport (`src/`)
|
|
100
|
-
- [x] Wire format — `MobileScreenEvent` (`type:'screen'`) + `MobileTouchEvent` in `@obsrviq/types`
|
|
101
|
-
- [
|
|
102
|
-
- [
|
|
103
|
-
- [
|
|
104
|
-
- [
|
|
127
|
+
- [x] Wire format — `MobileScreenEvent` (`type:'screen'`) + `MobileTouchEvent` (`type:'touch'`) in `@obsrviq/types`
|
|
128
|
+
- [x] Native iOS capture (Swift TurboModule) — screenshots + touch observer — `ios/`
|
|
129
|
+
- [x] Native Android capture (Kotlin TurboModule) — screenshots + touch observer — `android/`
|
|
130
|
+
- [x] Backend — route `type:'screen'` to blob + `mobile_frames`, `touch` to `events` (migration 017)
|
|
131
|
+
- [x] Player — mobile screenshot renderer + device-based routing + touch ripples + network waterfall/inspector
|
|
132
|
+
- [x] `<ObsrviqMask>` / `<ObsrviqMask unmask>` per-view privacy component (iOS + Android)
|
|
133
|
+
- [x] Network request/response **body** capture (opt-in, `captureNetworkBodies`)
|
|
105
134
|
|
|
106
135
|
## Building (native)
|
|
107
136
|
|
|
108
|
-
Native code can't be compiled in the SDK repo — build it inside a real RN app and test on devices (jank only shows on hardware, especially low-end). Codegen runs automatically during the app's iOS/Android build from [`src/spec/
|
|
137
|
+
Native code can't be compiled in the SDK repo — build it inside a real RN app and test on devices (jank only shows on hardware, especially low-end). Codegen runs automatically during the app's iOS/Android build from [`src/spec/NativeObsrviqReplay.ts`](src/spec/NativeObsrviqReplay.ts).
|
package/android/build.gradle
CHANGED
|
@@ -8,7 +8,7 @@ apply plugin: "org.jetbrains.kotlin.android" // REQUIRED: sources are Kotlin
|
|
|
8
8
|
apply plugin: "com.facebook.react" // enables New-Arch codegen for the TurboModule
|
|
9
9
|
|
|
10
10
|
android {
|
|
11
|
-
namespace "app.
|
|
11
|
+
namespace "app.obsrviq.replay"
|
|
12
12
|
compileSdkVersion safeExtGet("compileSdkVersion", 35)
|
|
13
13
|
|
|
14
14
|
defaultConfig {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
package app.obsrviq.replay
|
|
2
|
+
|
|
3
|
+
import android.view.View
|
|
4
|
+
import com.facebook.react.module.annotations.ReactModule
|
|
5
|
+
import com.facebook.react.uimanager.SimpleViewManager
|
|
6
|
+
import com.facebook.react.uimanager.ThemedReactContext
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Backs the optional `<ObsrviqMask>` JS component. It is a plain pass-through container
|
|
10
|
+
* (children render normally); its sole job is to tell the capture engine "redact my
|
|
11
|
+
* subtree". On attach it registers its own View.id in [ObsrviqReplayModule.maskedTags];
|
|
12
|
+
* on detach it removes it. This is the geometry [ObsrviqReplayModule.collectMaskRects]
|
|
13
|
+
* walks: an explicitly-masked container yields one redaction block over the whole subtree.
|
|
14
|
+
*
|
|
15
|
+
* Kept deliberately minimal — no custom props, no shadow node. JS can also drive masking
|
|
16
|
+
* imperatively via `NativeObsrviqReplay.setViewMasked(reactTag, masked)` without this view.
|
|
17
|
+
*/
|
|
18
|
+
@ReactModule(name = ObsrviqMaskViewManager.REACT_CLASS)
|
|
19
|
+
class ObsrviqMaskViewManager : SimpleViewManager<View>() {
|
|
20
|
+
|
|
21
|
+
override fun getName() = REACT_CLASS
|
|
22
|
+
|
|
23
|
+
override fun createViewInstance(reactContext: ThemedReactContext): View =
|
|
24
|
+
object : View(reactContext) {
|
|
25
|
+
override fun onAttachedToWindow() {
|
|
26
|
+
super.onAttachedToWindow()
|
|
27
|
+
ObsrviqReplayModule.maskTag(id)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
override fun onDetachedFromWindow() {
|
|
31
|
+
ObsrviqReplayModule.unmaskTag(id)
|
|
32
|
+
super.onDetachedFromWindow()
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
companion object {
|
|
37
|
+
// Must match the JS requireNativeComponent name + the iOS RCT_EXPORT_MODULE name.
|
|
38
|
+
// (Was "LumeraMask" on Android while iOS/JS used "LumeraMaskView" — a name mismatch
|
|
39
|
+
// that silently disabled the mask component on Android. Unified to "ObsrviqMaskView".)
|
|
40
|
+
const val REACT_CLASS = "ObsrviqMaskView"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
package app.
|
|
1
|
+
package app.obsrviq.replay
|
|
2
2
|
|
|
3
3
|
import android.graphics.Bitmap
|
|
4
4
|
import android.graphics.Canvas
|
|
@@ -30,7 +30,7 @@ import java.util.UUID
|
|
|
30
30
|
import java.util.concurrent.atomic.AtomicBoolean
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
*
|
|
33
|
+
* Obsrviq native screenshot-replay capture engine (Android).
|
|
34
34
|
*
|
|
35
35
|
* THREADING CONTRACT:
|
|
36
36
|
* • UI thread → only schedules a PixelCopy (which is async) + a cheap mask-rect walk.
|
|
@@ -41,14 +41,14 @@ import java.util.concurrent.atomic.AtomicBoolean
|
|
|
41
41
|
* which misses hardware-accelerated content. One reused RGB_565 bitmap. No pixels
|
|
42
42
|
* cross the RN bridge: this engine uploads frames itself to `${endpoint}/v1/batch`.
|
|
43
43
|
*
|
|
44
|
-
* NOTE: extends the codegen'd `
|
|
45
|
-
* `
|
|
44
|
+
* NOTE: extends the codegen'd `NativeObsrviqReplaySpec` (New Arch). The
|
|
45
|
+
* `ObsrviqReplayPackage` registers it as the TurboModule named "ObsrviqReplay".
|
|
46
46
|
*/
|
|
47
|
-
class
|
|
48
|
-
|
|
47
|
+
class ObsrviqReplayModule(private val reactCtx: ReactApplicationContext) :
|
|
48
|
+
NativeObsrviqReplaySpec(reactCtx) {
|
|
49
49
|
|
|
50
50
|
// Dedicated background thread for ALL post-capture work (PixelCopy callback + encode + upload).
|
|
51
|
-
private val bgThread = HandlerThread("
|
|
51
|
+
private val bgThread = HandlerThread("obsrviq-replay").apply { start() }
|
|
52
52
|
private val bg = Handler(bgThread.looper)
|
|
53
53
|
|
|
54
54
|
private var opts: ReadableStartOptions? = null
|
|
@@ -81,7 +81,7 @@ class LumeraReplayModule(private val reactCtx: ReactApplicationContext) :
|
|
|
81
81
|
private var framesDropped = 0
|
|
82
82
|
private var bytesSent = 0L
|
|
83
83
|
|
|
84
|
-
override fun getName() = "
|
|
84
|
+
override fun getName() = "ObsrviqReplay"
|
|
85
85
|
|
|
86
86
|
// MARK: - Lifecycle
|
|
87
87
|
|
|
@@ -323,7 +323,7 @@ class LumeraReplayModule(private val reactCtx: ReactApplicationContext) :
|
|
|
323
323
|
* Walk the decorView on the UI thread → redaction rects (in captured-pixel space) for
|
|
324
324
|
* masked nodes: TextView/EditText when [ReadableStartOptions.maskAllText], ImageView when
|
|
325
325
|
* [ReadableStartOptions.maskAllImages]. A view whose id (RN reactTag) is in [maskedTags]
|
|
326
|
-
* — set via `setViewMasked(tag, true)` or `<
|
|
326
|
+
* — set via `setViewMasked(tag, true)` or `<ObsrviqMask>` — is force-masked along with its
|
|
327
327
|
* whole subtree (one block), regardless of mask-all. Rect = getLocationInWindow + width/height.
|
|
328
328
|
*
|
|
329
329
|
* Honoring per-view UN-masking under mask-all (force-unmask) would need a second, explicit
|
|
@@ -397,7 +397,7 @@ class LumeraReplayModule(private val reactCtx: ReactApplicationContext) :
|
|
|
397
397
|
|
|
398
398
|
/**
|
|
399
399
|
* Assemble ONE `IngestBatch` from buffered `screen` events and POST it to
|
|
400
|
-
* `${endpoint}/v1/batch` (HttpURLConnection on `bg`, x-
|
|
400
|
+
* `${endpoint}/v1/batch` (HttpURLConnection on `bg`, x-obsrviq-key header, plain JSON).
|
|
401
401
|
* On HTTP 2xx: bump counters + ++seq. On failure: re-queue (prepend so order holds).
|
|
402
402
|
*/
|
|
403
403
|
private fun flush() {
|
|
@@ -462,7 +462,7 @@ class LumeraReplayModule(private val reactCtx: ReactApplicationContext) :
|
|
|
462
462
|
// octet-stream, NOT application/json — the ingest reads the raw body buffer and
|
|
463
463
|
// sniffs gzip itself; application/json hits Fastify's JSON parser → 400 empty_body.
|
|
464
464
|
setRequestProperty("content-type", "application/octet-stream")
|
|
465
|
-
setRequestProperty("x-
|
|
465
|
+
setRequestProperty("x-obsrviq-key", o.siteKey)
|
|
466
466
|
setFixedLengthStreamingMode(payload.size)
|
|
467
467
|
}
|
|
468
468
|
conn.outputStream.use { os: OutputStream -> os.write(payload) }
|
|
@@ -517,8 +517,8 @@ class LumeraReplayModule(private val reactCtx: ReactApplicationContext) :
|
|
|
517
517
|
companion object {
|
|
518
518
|
/**
|
|
519
519
|
* Process-wide set of explicitly-masked View ids (RN reactTags). Shared so both the
|
|
520
|
-
* TurboModule (`setViewMasked`) and the `<
|
|
521
|
-
* ([
|
|
520
|
+
* TurboModule (`setViewMasked`) and the `<ObsrviqMask>` view manager
|
|
521
|
+
* ([ObsrviqMaskViewManager]) feed the same registry the capture walk reads. A
|
|
522
522
|
* ConcurrentHashMap-backed set: written from the UI thread, read on `bg`.
|
|
523
523
|
*/
|
|
524
524
|
private val maskedTags: MutableSet<Int> =
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
package app.
|
|
1
|
+
package app.obsrviq.replay
|
|
2
2
|
|
|
3
3
|
import com.facebook.react.BaseReactPackage
|
|
4
4
|
import com.facebook.react.bridge.NativeModule
|
|
@@ -8,30 +8,30 @@ import com.facebook.react.module.model.ReactModuleInfoProvider
|
|
|
8
8
|
import com.facebook.react.uimanager.ViewManager
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* TurboModule package for the
|
|
11
|
+
* TurboModule package for the Obsrviq native capture engine.
|
|
12
12
|
*
|
|
13
13
|
* Registered by the host app (autolinking via react-native.config.js, or manually in
|
|
14
14
|
* MainApplication's `getPackages()`). Exposes the New-Architecture TurboModule named
|
|
15
|
-
* "
|
|
15
|
+
* "ObsrviqReplay" plus the optional <ObsrviqMask> view manager.
|
|
16
16
|
*
|
|
17
17
|
* Uses [BaseReactPackage] (RN 0.76+) — `getModule(name, ctx)` is resolved lazily by the
|
|
18
18
|
* TurboModule infrastructure, and [getReactModuleInfoProvider] marks the module
|
|
19
19
|
* `isTurboModule = true` so it is loaded over JSI (never the legacy bridge).
|
|
20
20
|
*/
|
|
21
|
-
class
|
|
21
|
+
class ObsrviqReplayPackage : BaseReactPackage() {
|
|
22
22
|
|
|
23
23
|
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? =
|
|
24
24
|
when (name) {
|
|
25
|
-
|
|
25
|
+
ObsrviqReplayModuleName -> ObsrviqReplayModule(reactContext)
|
|
26
26
|
else -> null
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
override fun getReactModuleInfoProvider(): ReactModuleInfoProvider =
|
|
30
30
|
ReactModuleInfoProvider {
|
|
31
31
|
mapOf(
|
|
32
|
-
|
|
33
|
-
/* name = */
|
|
34
|
-
/* className = */
|
|
32
|
+
ObsrviqReplayModuleName to ReactModuleInfo(
|
|
33
|
+
/* name = */ ObsrviqReplayModuleName,
|
|
34
|
+
/* className = */ ObsrviqReplayModule::class.java.name,
|
|
35
35
|
/* canOverrideExistingModule = */ false,
|
|
36
36
|
/* needsEagerInit = */ false,
|
|
37
37
|
/* isCxxModule = */ false,
|
|
@@ -40,11 +40,11 @@ class LumeraReplayPackage : BaseReactPackage() {
|
|
|
40
40
|
)
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
// View managers are not TurboModules; supply the optional <
|
|
43
|
+
// View managers are not TurboModules; supply the optional <ObsrviqMask> manager directly.
|
|
44
44
|
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
|
|
45
|
-
listOf(
|
|
45
|
+
listOf(ObsrviqMaskViewManager())
|
|
46
46
|
|
|
47
47
|
private companion object {
|
|
48
|
-
const val
|
|
48
|
+
const val ObsrviqReplayModuleName = "ObsrviqReplay"
|
|
49
49
|
}
|
|
50
50
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
NS_ASSUME_NONNULL_BEGIN
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Backing native view for the <
|
|
6
|
+
* Backing native view for the <ObsrviqMask> RN component. It is a plain pass-through
|
|
7
7
|
* container (children render normally); its only job is to register/unregister its RN
|
|
8
8
|
* `reactTag` with the capture engine so the region it occupies is masked (or, with
|
|
9
9
|
* `masked={false}`, explicitly UN-masked — overriding the global maskAll* defaults).
|
|
@@ -11,7 +11,7 @@ NS_ASSUME_NONNULL_BEGIN
|
|
|
11
11
|
* The mask itself is composited natively off the UI thread during capture — this view
|
|
12
12
|
* adds no per-frame cost and never rasterizes anything.
|
|
13
13
|
*/
|
|
14
|
-
@interface
|
|
14
|
+
@interface ObsrviqMaskView : UIView
|
|
15
15
|
/** YES → mask this subtree; NO → force-unmask it. Default YES. */
|
|
16
16
|
@property (nonatomic, assign) BOOL masked;
|
|
17
17
|
@end
|
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
#import "
|
|
1
|
+
#import "ObsrviqMaskView.h"
|
|
2
2
|
#import <React/RCTComponent.h>
|
|
3
3
|
|
|
4
|
-
// The capture engine's @objc surface (see
|
|
5
|
-
// header resolution in
|
|
6
|
-
#if __has_include(<
|
|
7
|
-
#import <
|
|
8
|
-
#elif __has_include("
|
|
9
|
-
#import "
|
|
10
|
-
#elif __has_include("
|
|
11
|
-
#import "
|
|
4
|
+
// The capture engine's @objc surface (see ObsrviqReplay.swift). Mirrors the umbrella-
|
|
5
|
+
// header resolution in ObsrviqReplayModule.mm; we only need `shared` + setViewMasked here.
|
|
6
|
+
#if __has_include(<obsrviq_react_native/obsrviq_react_native-Swift.h>)
|
|
7
|
+
#import <obsrviq_react_native/obsrviq_react_native-Swift.h>
|
|
8
|
+
#elif __has_include("obsrviq_react_native-Swift.h")
|
|
9
|
+
#import "obsrviq_react_native-Swift.h"
|
|
10
|
+
#elif __has_include("ObsrviqReactNative-Swift.h")
|
|
11
|
+
#import "ObsrviqReactNative-Swift.h"
|
|
12
12
|
#else
|
|
13
|
-
@interface
|
|
14
|
-
@property (class, nonatomic, readonly, strong)
|
|
13
|
+
@interface ObsrviqReplay : NSObject
|
|
14
|
+
@property (class, nonatomic, readonly, strong) ObsrviqReplay *shared;
|
|
15
15
|
- (void)setViewMasked:(NSNumber *)reactTag masked:(NSNumber *)masked;
|
|
16
16
|
@end
|
|
17
17
|
#endif
|
|
18
18
|
|
|
19
|
-
@implementation
|
|
19
|
+
@implementation ObsrviqMaskView
|
|
20
20
|
|
|
21
21
|
- (instancetype)init
|
|
22
22
|
{
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
// registering → honor the `masked` prop (YES = mask, NO = force-unmask).
|
|
64
64
|
// !registering (leaving the tree) → clear the entry by unmasking.
|
|
65
65
|
BOOL value = registering ? self.masked : NO;
|
|
66
|
-
[
|
|
66
|
+
[ObsrviqReplay.shared setViewMasked:tag masked:@(value)];
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
- (void)dealloc
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
// without a willMoveToSuperview:nil (shouldn't happen, but tags must not leak).
|
|
73
73
|
NSNumber *tag = self.reactTag;
|
|
74
74
|
if (tag != nil) {
|
|
75
|
-
[
|
|
75
|
+
[ObsrviqReplay.shared setViewMasked:tag masked:@(NO)];
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
#import <React/RCTViewManager.h>
|
|
2
|
-
#import "
|
|
2
|
+
#import "ObsrviqMaskView.h"
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* View manager for the <
|
|
6
|
-
* "
|
|
5
|
+
* View manager for the <ObsrviqMask> RN component (registered as native component
|
|
6
|
+
* "ObsrviqMaskView"). It exposes a single `masked` prop and returns a ObsrviqMaskView,
|
|
7
7
|
* which (un)registers its reactTag with the capture engine on mount/unmount.
|
|
8
8
|
*
|
|
9
9
|
* The package's codegenConfig is `type: "modules"` (TurboModule only — no Fabric
|
|
10
10
|
* component spec), so this legacy RCTViewManager is the right minimal surface: RN's
|
|
11
11
|
* view interop layer bridges it under both the old and new architectures.
|
|
12
12
|
*/
|
|
13
|
-
@interface
|
|
13
|
+
@interface ObsrviqMaskViewManager : RCTViewManager
|
|
14
14
|
@end
|
|
15
15
|
|
|
16
|
-
@implementation
|
|
16
|
+
@implementation ObsrviqMaskViewManager
|
|
17
17
|
|
|
18
|
-
RCT_EXPORT_MODULE(
|
|
18
|
+
RCT_EXPORT_MODULE(ObsrviqMaskView)
|
|
19
19
|
|
|
20
20
|
RCT_EXPORT_VIEW_PROPERTY(masked, BOOL)
|
|
21
21
|
|
|
@@ -23,7 +23,7 @@ RCT_EXPORT_VIEW_PROPERTY(masked, BOOL)
|
|
|
23
23
|
|
|
24
24
|
- (UIView *)view
|
|
25
25
|
{
|
|
26
|
-
return [
|
|
26
|
+
return [ObsrviqMaskView new];
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
@end
|
|
@@ -3,7 +3,7 @@ import UIKit
|
|
|
3
3
|
import UIKit.UIGestureRecognizerSubclass // touchesBegan/Moved/Ended overrides
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
6
|
+
* Obsrviq native screenshot-replay capture engine (iOS).
|
|
7
7
|
*
|
|
8
8
|
* THREADING CONTRACT (the whole point):
|
|
9
9
|
* • UI thread → TWO things only: the `view.drawHierarchy(in:afterScreenUpdates:false)`
|
|
@@ -17,11 +17,11 @@ import UIKit.UIGestureRecognizerSubclass // touchesBegan/Moved/Ended overrides
|
|
|
17
17
|
* (independent of the JS structured-event stream's seq; the worker keys frames by
|
|
18
18
|
* event id, not seq, so the two streams never collide).
|
|
19
19
|
*
|
|
20
|
-
* Reached from the ObjC++ TurboModule bridge (
|
|
20
|
+
* Reached from the ObjC++ TurboModule bridge (ObsrviqReplayModule.mm) via the `@objc`
|
|
21
21
|
* wrappers at the bottom of this file.
|
|
22
22
|
*/
|
|
23
|
-
@objc(
|
|
24
|
-
final class
|
|
23
|
+
@objc(ObsrviqReplay)
|
|
24
|
+
final class ObsrviqReplay: NSObject {
|
|
25
25
|
|
|
26
26
|
struct Options {
|
|
27
27
|
var endpoint: String
|
|
@@ -39,10 +39,10 @@ final class LumeraReplay: NSObject {
|
|
|
39
39
|
|
|
40
40
|
/// One process-wide instance — the bridge forwards every call here so the JS
|
|
41
41
|
/// surface (start/stop/configure/mask/diagnostics) talks to a single engine.
|
|
42
|
-
@objc static let shared =
|
|
42
|
+
@objc static let shared = ObsrviqReplay()
|
|
43
43
|
|
|
44
44
|
// Serial background queue: ALL post-capture work (mask/encode/JSON/upload) lands here.
|
|
45
|
-
private let work = DispatchQueue(label: "app.
|
|
45
|
+
private let work = DispatchQueue(label: "app.obsrviq.replay.work", qos: .utility)
|
|
46
46
|
// URLSession on the background queue — uploads never touch the JS bridge or main thread.
|
|
47
47
|
private let session: URLSession = {
|
|
48
48
|
let cfg = URLSessionConfiguration.default
|
|
@@ -102,7 +102,7 @@ final class LumeraReplay: NSObject {
|
|
|
102
102
|
// Passive touch observer: a non-consuming gesture recognizer on the key window
|
|
103
103
|
// records taps + swipes without interfering with the app's own gestures.
|
|
104
104
|
if opts.captureTouches, self.touchRecognizer == nil, let window = self.keyWindow() {
|
|
105
|
-
let r =
|
|
105
|
+
let r = ObsrviqTouchRecognizer(target: self, action: #selector(self.touchNoop))
|
|
106
106
|
r.sink = self
|
|
107
107
|
window.addGestureRecognizer(r)
|
|
108
108
|
self.touchRecognizer = r
|
|
@@ -485,7 +485,7 @@ final class LumeraReplay: NSObject {
|
|
|
485
485
|
|
|
486
486
|
var req = URLRequest(url: url)
|
|
487
487
|
req.httpMethod = "POST"
|
|
488
|
-
req.setValue(opts.siteKey, forHTTPHeaderField: "x-
|
|
488
|
+
req.setValue(opts.siteKey, forHTTPHeaderField: "x-obsrviq-key")
|
|
489
489
|
// octet-stream, NOT application/json — the ingest reads the raw body buffer and
|
|
490
490
|
// sniffs gzip itself; application/json hits Fastify's JSON parser → 400 empty_body.
|
|
491
491
|
req.setValue("application/octet-stream", forHTTPHeaderField: "content-type")
|
|
@@ -615,7 +615,7 @@ final class LumeraReplay: NSObject {
|
|
|
615
615
|
/// RFC 4122 v4 UUID for frame ids (the worker keys frame blobs by this — must be unique).
|
|
616
616
|
private static func uuidv4() -> String { UUID().uuidString.lowercased() }
|
|
617
617
|
|
|
618
|
-
// MARK: - @objc bridge surface (called from
|
|
618
|
+
// MARK: - @objc bridge surface (called from ObsrviqReplayModule.mm)
|
|
619
619
|
//
|
|
620
620
|
// The ObjC++ TurboModule passes NSDictionary option maps straight through; these
|
|
621
621
|
// wrappers unbox them into the Swift `Options` struct and forward to the real methods.
|
|
@@ -671,8 +671,8 @@ final class LumeraReplay: NSObject {
|
|
|
671
671
|
/// A passive (non-consuming) touch observer added to the key window. Records taps +
|
|
672
672
|
/// swipe samples and forwards them to the engine without interfering with the app's
|
|
673
673
|
/// own gesture handling (cancelsTouchesInView = false; it never claims a gesture).
|
|
674
|
-
final class
|
|
675
|
-
weak var sink:
|
|
674
|
+
final class ObsrviqTouchRecognizer: UIGestureRecognizer {
|
|
675
|
+
weak var sink: ObsrviqReplay?
|
|
676
676
|
|
|
677
677
|
override init(target: Any?, action: Selector?) {
|
|
678
678
|
super.init(target: target, action: action)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#import <Foundation/Foundation.h>
|
|
2
|
+
#import <React/RCTBridgeModule.h>
|
|
3
|
+
|
|
4
|
+
NS_ASSUME_NONNULL_BEGIN
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The native screenshot-capture TurboModule, exposed to JS as "ObsrviqReplay".
|
|
8
|
+
*
|
|
9
|
+
* This thin ObjC++ shell exists only to satisfy React Native's codegen'd New-Arch
|
|
10
|
+
* protocol (NativeObsrviqReplaySpec, generated from src/spec/NativeObsrviqReplay.ts via
|
|
11
|
+
* the `ObsrviqReplaySpec` codegen target) and to forward each call to the shared Swift
|
|
12
|
+
* engine `ObsrviqReplay`, which owns the entire capture → mask → encode → upload loop.
|
|
13
|
+
*
|
|
14
|
+
* No capture logic lives here. The implementation (.mm) conforms to the generated
|
|
15
|
+
* <NativeObsrviqReplaySpec> protocol and implements `getTurboModule:` for the New Arch.
|
|
16
|
+
*/
|
|
17
|
+
@interface ObsrviqReplayModule : NSObject <RCTBridgeModule>
|
|
18
|
+
@end
|
|
19
|
+
|
|
20
|
+
NS_ASSUME_NONNULL_END
|
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
#import "
|
|
1
|
+
#import "ObsrviqReplayModule.h"
|
|
2
2
|
|
|
3
|
-
// The Swift engine (
|
|
3
|
+
// The Swift engine (ObsrviqReplay, @objc-exposed) is surfaced to ObjC++ through the
|
|
4
4
|
// pod's auto-generated Swift umbrella header. CocoaPods names it
|
|
5
|
-
// "<module_name>-Swift.h"; for this pod (`
|
|
6
|
-
// to `
|
|
5
|
+
// "<module_name>-Swift.h"; for this pod (`obsrviq-react-native`) Clang sanitizes that
|
|
6
|
+
// to `obsrviq_react_native-Swift.h`. We tolerate either spelling so the bridge builds
|
|
7
7
|
// whether the host app links us as a pod (umbrella under the pod name) or compiles the
|
|
8
8
|
// sources into the app target (umbrella under the app's product module name).
|
|
9
|
-
#if __has_include(<
|
|
10
|
-
#import <
|
|
11
|
-
#elif __has_include("
|
|
12
|
-
#import "
|
|
13
|
-
#elif __has_include("
|
|
14
|
-
#import "
|
|
9
|
+
#if __has_include(<obsrviq_react_native/obsrviq_react_native-Swift.h>)
|
|
10
|
+
#import <obsrviq_react_native/obsrviq_react_native-Swift.h>
|
|
11
|
+
#elif __has_include("obsrviq_react_native-Swift.h")
|
|
12
|
+
#import "obsrviq_react_native-Swift.h"
|
|
13
|
+
#elif __has_include("ObsrviqReactNative-Swift.h")
|
|
14
|
+
#import "ObsrviqReactNative-Swift.h"
|
|
15
15
|
#else
|
|
16
16
|
// Fallback: forward-declare the @objc surface we call so the bridge still compiles if
|
|
17
17
|
// the umbrella header name differs in a given app. The linker resolves it from the
|
|
18
18
|
// Swift object at link time.
|
|
19
|
-
@interface
|
|
20
|
-
@property (class, nonatomic, readonly, strong)
|
|
19
|
+
@interface ObsrviqReplay : NSObject
|
|
20
|
+
@property (class, nonatomic, readonly, strong) ObsrviqReplay *shared;
|
|
21
21
|
- (void)startWithOptions:(NSDictionary *)options;
|
|
22
22
|
- (void)stopObjc;
|
|
23
23
|
- (void)configureWithOptions:(NSDictionary *)options;
|
|
@@ -26,19 +26,19 @@
|
|
|
26
26
|
@end
|
|
27
27
|
#endif
|
|
28
28
|
|
|
29
|
-
// The codegen'd New-Arch spec header (target name `
|
|
29
|
+
// The codegen'd New-Arch spec header (target name `ObsrviqReplaySpec`, from
|
|
30
30
|
// package.json → codegenConfig.name). Present only when codegen has run (i.e. inside a
|
|
31
31
|
// real app build with the New Architecture). Guarded so this file also compiles on the
|
|
32
32
|
// old architecture, where the RCT_EXPORT_METHOD declarations below carry the module.
|
|
33
33
|
#ifdef RCT_NEW_ARCH_ENABLED
|
|
34
|
-
#import <
|
|
34
|
+
#import <ObsrviqReplaySpec/ObsrviqReplaySpec.h>
|
|
35
35
|
#endif
|
|
36
36
|
|
|
37
|
-
@implementation
|
|
37
|
+
@implementation ObsrviqReplayModule
|
|
38
38
|
|
|
39
|
-
// Registers this as the TurboModule/NativeModule named "
|
|
40
|
-
// TurboModuleRegistry.getEnforcing<Spec>('
|
|
41
|
-
RCT_EXPORT_MODULE(
|
|
39
|
+
// Registers this as the TurboModule/NativeModule named "ObsrviqReplay" — the exact name
|
|
40
|
+
// TurboModuleRegistry.getEnforcing<Spec>('ObsrviqReplay') resolves on the JS side.
|
|
41
|
+
RCT_EXPORT_MODULE(ObsrviqReplay)
|
|
42
42
|
|
|
43
43
|
// All forwarding is trivial (dispatch onto the engine, which owns its own threads); no
|
|
44
44
|
// reason to hop onto a method queue.
|
|
@@ -49,44 +49,44 @@ RCT_EXPORT_MODULE(LumeraReplay)
|
|
|
49
49
|
// `start(options)` — object arg arrives as NSDictionary at the ObjC boundary.
|
|
50
50
|
RCT_EXPORT_METHOD(start:(NSDictionary *)options)
|
|
51
51
|
{
|
|
52
|
-
[
|
|
52
|
+
[ObsrviqReplay.shared startWithOptions:options];
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
// `stop()`
|
|
56
56
|
RCT_EXPORT_METHOD(stop)
|
|
57
57
|
{
|
|
58
|
-
[
|
|
58
|
+
[ObsrviqReplay.shared stopObjc];
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
// `configure(options)`
|
|
62
62
|
RCT_EXPORT_METHOD(configure:(NSDictionary *)options)
|
|
63
63
|
{
|
|
64
|
-
[
|
|
64
|
+
[ObsrviqReplay.shared configureWithOptions:options];
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
// `setViewMasked(reactTag, masked)` — numbers cross as `double`/`BOOL` on New Arch.
|
|
68
68
|
RCT_EXPORT_METHOD(setViewMasked:(double)reactTag masked:(BOOL)masked)
|
|
69
69
|
{
|
|
70
|
-
[
|
|
70
|
+
[ObsrviqReplay.shared setViewMasked:@(reactTag) masked:@(masked)];
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
// `getDiagnostics(): Promise<NativeDiagnostics>` — resolves with the counters snapshot.
|
|
74
74
|
RCT_EXPORT_METHOD(getDiagnostics:(RCTPromiseResolveBlock)resolve
|
|
75
75
|
reject:(RCTPromiseRejectBlock)reject)
|
|
76
76
|
{
|
|
77
|
-
resolve([
|
|
77
|
+
resolve([ObsrviqReplay.shared getDiagnosticsObjc]);
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
#pragma mark - New Architecture (TurboModule) wiring
|
|
81
81
|
|
|
82
82
|
#ifdef RCT_NEW_ARCH_ENABLED
|
|
83
|
-
// Hands the runtime the C++ JSI binding generated for our spec. `
|
|
84
|
-
// is emitted by codegen from src/spec/
|
|
83
|
+
// Hands the runtime the C++ JSI binding generated for our spec. `NativeObsrviqReplaySpecJSI`
|
|
84
|
+
// is emitted by codegen from src/spec/NativeObsrviqReplay.ts (spec target `ObsrviqReplaySpec`)
|
|
85
85
|
// and dispatches each JSI call to the RCT_EXPORT_METHOD implementations above.
|
|
86
86
|
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
|
|
87
87
|
(const facebook::react::ObjCTurboModule::InitParams &)params
|
|
88
88
|
{
|
|
89
|
-
return std::make_shared<facebook::react::
|
|
89
|
+
return std::make_shared<facebook::react::NativeObsrviqReplaySpecJSI>(params);
|
|
90
90
|
}
|
|
91
91
|
#endif
|
|
92
92
|
|
|
@@ -3,17 +3,17 @@ require "json"
|
|
|
3
3
|
package = JSON.parse(File.read(File.join(__dir__, "package.json")))
|
|
4
4
|
|
|
5
5
|
Pod::Spec.new do |s|
|
|
6
|
-
s.name = "
|
|
6
|
+
s.name = "obsrviq-react-native"
|
|
7
7
|
s.version = package["version"]
|
|
8
8
|
s.summary = package["description"]
|
|
9
|
-
s.homepage = "https://
|
|
9
|
+
s.homepage = "https://obsrviq.com"
|
|
10
10
|
s.license = "MIT"
|
|
11
|
-
s.authors = "
|
|
11
|
+
s.authors = "Obsrviq"
|
|
12
12
|
s.platforms = { :ios => "13.4" }
|
|
13
|
-
s.source = { :git => "https://github.com/TedoraTech/
|
|
13
|
+
s.source = { :git => "https://github.com/TedoraTech/obsrviq.git", :tag => "#{s.version}" }
|
|
14
14
|
|
|
15
15
|
s.source_files = "ios/**/*.{h,m,mm,swift}"
|
|
16
16
|
|
|
17
|
-
# Pulls in React-Core, and (on New Arch) the codegen'd
|
|
17
|
+
# Pulls in React-Core, and (on New Arch) the codegen'd ObsrviqReplaySpec + RN-Core deps.
|
|
18
18
|
install_modules_dependencies(s)
|
|
19
19
|
end
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@obsrviq/react-native",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
7
|
-
"description": "
|
|
7
|
+
"description": "Obsrviq mobile capture SDK for React Native — privacy-first screenshot session replay + network/error/custom-event recording, engineered for zero added latency on device.",
|
|
8
8
|
"main": "src/index.ts",
|
|
9
9
|
"module": "src/index.ts",
|
|
10
10
|
"react-native": "src/index.ts",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"src",
|
|
15
15
|
"ios",
|
|
16
16
|
"android",
|
|
17
|
-
"
|
|
17
|
+
"obsrviq-react-native.podspec",
|
|
18
18
|
"!**/__tests__"
|
|
19
19
|
],
|
|
20
20
|
"dependencies": {
|
|
@@ -33,11 +33,11 @@
|
|
|
33
33
|
"typescript": "^5.7.2"
|
|
34
34
|
},
|
|
35
35
|
"codegenConfig": {
|
|
36
|
-
"name": "
|
|
36
|
+
"name": "ObsrviqReplaySpec",
|
|
37
37
|
"type": "modules",
|
|
38
38
|
"jsSrcsDir": "src/spec",
|
|
39
39
|
"android": {
|
|
40
|
-
"javaPackageName": "app.
|
|
40
|
+
"javaPackageName": "app.obsrviq.replay"
|
|
41
41
|
}
|
|
42
42
|
},
|
|
43
43
|
"scripts": {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Platform, View, type ViewProps, requireNativeComponent } from 'react-native';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* <ObsrviqMask> — privacy-by-region wrapper for the native screenshot replay.
|
|
6
|
+
*
|
|
7
|
+
* Wrap any subtree to control whether it is redacted in captured frames:
|
|
8
|
+
*
|
|
9
|
+
* <ObsrviqMask> …</ObsrviqMask> // force-mask this subtree
|
|
10
|
+
* <ObsrviqMask unmask> …</ObsrviqMask> // force-UNMASK (overrides maskAllText/Images)
|
|
11
|
+
*
|
|
12
|
+
* Under the hood the native view (un)registers its reactTag with the capture engine via
|
|
13
|
+
* `setViewMasked(reactTag, …)`. Masking is composited natively, off the UI thread, during
|
|
14
|
+
* capture — this component renders its children verbatim and adds no per-frame cost.
|
|
15
|
+
*
|
|
16
|
+
* On unsupported platforms / unlinked builds (e.g. Expo Go) it degrades to a plain <View>
|
|
17
|
+
* so layout is unaffected. (`<LumeraMask>` remains exported as a deprecated alias.)
|
|
18
|
+
*/
|
|
19
|
+
export interface ObsrviqMaskProps extends ViewProps {
|
|
20
|
+
/** Force-unmask this subtree (overrides the global maskAllText/maskAllImages defaults). */
|
|
21
|
+
unmask?: boolean;
|
|
22
|
+
children?: React.ReactNode;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface NativeMaskProps extends ViewProps {
|
|
26
|
+
masked: boolean;
|
|
27
|
+
children?: React.ReactNode;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Resolve the native component lazily + defensively. Prefer the Obsrviq-era view, fall
|
|
31
|
+
// back to the legacy "LumeraMaskView" name (pre-rebrand native binaries); if neither is
|
|
32
|
+
// linked the require throws and we degrade to a plain View (capture just won't mask here).
|
|
33
|
+
const NativeObsrviqMask: React.ComponentType<NativeMaskProps> | null = (() => {
|
|
34
|
+
if (Platform.OS !== 'ios' && Platform.OS !== 'android') return null;
|
|
35
|
+
// Try the Obsrviq view, then legacy names: "LumeraMaskView" (iOS pre-rebrand) and
|
|
36
|
+
// "LumeraMask" (Android pre-rebrand — they disagreed before the rename).
|
|
37
|
+
for (const name of ['ObsrviqMaskView', 'LumeraMaskView', 'LumeraMask']) {
|
|
38
|
+
try {
|
|
39
|
+
return requireNativeComponent<NativeMaskProps>(name);
|
|
40
|
+
} catch {
|
|
41
|
+
/* try the next name */
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
})();
|
|
46
|
+
|
|
47
|
+
export function ObsrviqMask({ unmask, children, ...rest }: ObsrviqMaskProps): React.ReactElement {
|
|
48
|
+
if (!NativeObsrviqMask) {
|
|
49
|
+
return <View {...rest}>{children}</View>;
|
|
50
|
+
}
|
|
51
|
+
return (
|
|
52
|
+
<NativeObsrviqMask masked={!unmask} {...rest}>
|
|
53
|
+
{children}
|
|
54
|
+
</NativeObsrviqMask>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** @deprecated Renamed to {@link ObsrviqMask}. Kept as an alias for the Lumera era. */
|
|
59
|
+
export const LumeraMask = ObsrviqMask;
|
|
60
|
+
/** @deprecated Renamed to {@link ObsrviqMaskProps}. */
|
|
61
|
+
export type LumeraMaskProps = ObsrviqMaskProps;
|
|
62
|
+
|
|
63
|
+
export default ObsrviqMask;
|
package/src/config.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { IngestBatch } from '@obsrviq/types';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Public init config for the
|
|
4
|
+
* Public init config for the Obsrviq React Native SDK. Mirrors the web tracker's
|
|
5
5
|
* `ObsrviqInitConfig` where it makes sense, swapping DOM-specific privacy knobs for
|
|
6
6
|
* the native screenshot-replay knobs (fps / jpegQuality / maskAll*).
|
|
7
7
|
*/
|
|
8
|
-
export interface
|
|
8
|
+
export interface ObsrviqConfig {
|
|
9
9
|
/** REQUIRED — your ingest key (pk_…). */
|
|
10
10
|
siteKey: string;
|
|
11
11
|
|
|
@@ -66,9 +66,9 @@ export interface LumeraConfig {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
export type ResolvedConfig = Required<
|
|
69
|
-
Omit<
|
|
69
|
+
Omit<ObsrviqConfig, 'userId' | 'metadata' | 'beforeSend'>
|
|
70
70
|
> &
|
|
71
|
-
Pick<
|
|
71
|
+
Pick<ObsrviqConfig, 'userId' | 'metadata' | 'beforeSend'>;
|
|
72
72
|
|
|
73
73
|
export const DEFAULTS: Omit<ResolvedConfig, 'siteKey' | 'userId' | 'metadata' | 'beforeSend'> = {
|
|
74
74
|
enableReplay: true,
|
|
@@ -89,8 +89,11 @@ export const DEFAULTS: Omit<ResolvedConfig, 'siteKey' | 'userId' | 'metadata' |
|
|
|
89
89
|
requireConsent: false,
|
|
90
90
|
};
|
|
91
91
|
|
|
92
|
-
export function resolveConfig(config:
|
|
92
|
+
export function resolveConfig(config: ObsrviqConfig): ResolvedConfig {
|
|
93
93
|
const clean: Record<string, unknown> = {};
|
|
94
94
|
for (const [k, v] of Object.entries(config)) if (v !== undefined) clean[k] = v;
|
|
95
95
|
return { ...DEFAULTS, ...(clean as Partial<ResolvedConfig>), siteKey: config.siteKey };
|
|
96
96
|
}
|
|
97
|
+
|
|
98
|
+
/** @deprecated Renamed to {@link ObsrviqConfig}. Kept as an alias for the Lumera era. */
|
|
99
|
+
export type LumeraConfig = ObsrviqConfig;
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Dimensions, Platform } from 'react-native';
|
|
2
2
|
import type { IngestBatch, IngestSessionMeta, ObsrviqEvent } from '@obsrviq/types';
|
|
3
|
-
import
|
|
4
|
-
import { type
|
|
3
|
+
import NativeObsrviqReplay from './spec/NativeObsrviqReplay';
|
|
4
|
+
import { type ObsrviqConfig, type ResolvedConfig, resolveConfig } from './config';
|
|
5
5
|
import { Clock, uuid } from './util';
|
|
6
6
|
import { clearSession, persistProgress, resolveSession, type ResolvedSession } from './session';
|
|
7
7
|
import { Transport } from './transport';
|
|
@@ -19,13 +19,13 @@ export interface Diagnostics {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
*
|
|
22
|
+
* Obsrviq React Native capture SDK. Mirrors the web tracker's public API 1:1, so app
|
|
23
23
|
* code reads the same on web and mobile. The pure-JS side here owns identity, the
|
|
24
24
|
* structured-event stream (network / error / console / custom), and transport; the
|
|
25
25
|
* heavy screenshot replay stream is owned end-to-end by the native TurboModule
|
|
26
26
|
* (capture → mask → encode → upload), which never blocks the JS thread.
|
|
27
27
|
*/
|
|
28
|
-
class
|
|
28
|
+
class ObsrviqReplayClient {
|
|
29
29
|
private cfg: ResolvedConfig | null = null;
|
|
30
30
|
private clock: Clock | null = null;
|
|
31
31
|
private transport: Transport | null = null;
|
|
@@ -47,7 +47,7 @@ class LumeraReplayClient {
|
|
|
47
47
|
|
|
48
48
|
/** Initialize the SDK. Idempotent. Session resolution is async (AsyncStorage); any
|
|
49
49
|
* events emitted before it resolves are buffered and timestamped correctly. */
|
|
50
|
-
init(config:
|
|
50
|
+
init(config: ObsrviqConfig): void {
|
|
51
51
|
if (this.cfg) return;
|
|
52
52
|
const cfg = resolveConfig(config);
|
|
53
53
|
// Sampling: decide once, up front. A sampled-out session records nothing.
|
|
@@ -121,7 +121,7 @@ class LumeraReplayClient {
|
|
|
121
121
|
// ── Native screenshot capture (owns capture + encode + upload, off the UI/JS thread) ──
|
|
122
122
|
if (cfg.enableReplay) {
|
|
123
123
|
try {
|
|
124
|
-
|
|
124
|
+
NativeObsrviqReplay.start({
|
|
125
125
|
endpoint: cfg.endpoint,
|
|
126
126
|
siteKey: cfg.siteKey,
|
|
127
127
|
sessionId: this._sessionId!,
|
|
@@ -237,7 +237,7 @@ class LumeraReplayClient {
|
|
|
237
237
|
for (const u of this.uninstalls.splice(0)) u();
|
|
238
238
|
if (this.cfg?.enableReplay) {
|
|
239
239
|
try {
|
|
240
|
-
|
|
240
|
+
NativeObsrviqReplay.stop();
|
|
241
241
|
} catch {
|
|
242
242
|
/* not linked */
|
|
243
243
|
}
|
|
@@ -284,9 +284,15 @@ class LumeraReplayClient {
|
|
|
284
284
|
}
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
-
export const
|
|
288
|
-
export default
|
|
289
|
-
export type {
|
|
287
|
+
export const ObsrviqReplay = new ObsrviqReplayClient();
|
|
288
|
+
export default ObsrviqReplay;
|
|
289
|
+
export type { ObsrviqConfig };
|
|
290
290
|
|
|
291
|
-
|
|
292
|
-
export
|
|
291
|
+
/** @deprecated Renamed to {@link ObsrviqReplay}. Kept as an alias for the Lumera era. */
|
|
292
|
+
export const LumeraReplay = ObsrviqReplay;
|
|
293
|
+
/** @deprecated Renamed to {@link ObsrviqConfig}. */
|
|
294
|
+
export type { ObsrviqConfig as LumeraConfig };
|
|
295
|
+
|
|
296
|
+
// Privacy-by-region component for the native screenshot replay (<ObsrviqMask> / <ObsrviqMask unmask>).
|
|
297
|
+
// `LumeraMask` is re-exported as a deprecated alias.
|
|
298
|
+
export { ObsrviqMask, LumeraMask, type ObsrviqMaskProps, type LumeraMaskProps } from './ObsrviqMask';
|
package/src/session.ts
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
2
2
|
import { uuid } from './util';
|
|
3
3
|
|
|
4
|
-
const SESSION_KEY = '
|
|
5
|
-
const VISITOR_KEY = '
|
|
4
|
+
const SESSION_KEY = 'obsrviq.session';
|
|
5
|
+
const VISITOR_KEY = 'obsrviq.visitor';
|
|
6
|
+
// Pre-rebrand keys — still read so an upgrading app keeps its visitor identity + any
|
|
7
|
+
// in-flight session continuation; we migrate onto the new keys on the next write.
|
|
8
|
+
const LEGACY_SESSION_KEY = 'lumera.session';
|
|
9
|
+
const LEGACY_VISITOR_KEY = 'lumera.visitor';
|
|
10
|
+
|
|
11
|
+
/** Read the current key, falling back to the legacy Lumera key for upgrading apps. */
|
|
12
|
+
async function getStored(key: string, legacyKey: string): Promise<string | null> {
|
|
13
|
+
return (await AsyncStorage.getItem(key)) ?? (await AsyncStorage.getItem(legacyKey));
|
|
14
|
+
}
|
|
6
15
|
|
|
7
16
|
interface StoredSession {
|
|
8
17
|
id: string;
|
|
@@ -54,7 +63,7 @@ export async function resolveSession(opts: {
|
|
|
54
63
|
const now = opts.now ?? Date.now();
|
|
55
64
|
|
|
56
65
|
// ── Visitor (persistent, anonymous) ──
|
|
57
|
-
let visitor = safeParse<StoredVisitor>(await
|
|
66
|
+
let visitor = safeParse<StoredVisitor>(await getStored(VISITOR_KEY, LEGACY_VISITOR_KEY));
|
|
58
67
|
if (!visitor) {
|
|
59
68
|
visitor = { id: uuid(), firstSeenAt: now, visitCount: 0 };
|
|
60
69
|
}
|
|
@@ -62,7 +71,7 @@ export async function resolveSession(opts: {
|
|
|
62
71
|
// ── Session (optional continuation) ──
|
|
63
72
|
let session: StoredSession | null = null;
|
|
64
73
|
if (opts.continueSession) {
|
|
65
|
-
const prior = safeParse<StoredSession>(await
|
|
74
|
+
const prior = safeParse<StoredSession>(await getStored(SESSION_KEY, LEGACY_SESSION_KEY));
|
|
66
75
|
const fresh = prior && now - prior.lastActivity < opts.timeoutMs;
|
|
67
76
|
const sameUser = !opts.userId || !prior?.userId || prior.userId === opts.userId;
|
|
68
77
|
if (prior && fresh && sameUser) session = prior;
|
|
@@ -80,6 +89,8 @@ export async function resolveSession(opts: {
|
|
|
80
89
|
[SESSION_KEY, JSON.stringify(session)],
|
|
81
90
|
[VISITOR_KEY, JSON.stringify(visitor)],
|
|
82
91
|
]);
|
|
92
|
+
// Migrate off the legacy keys so they don't go stale alongside the new ones.
|
|
93
|
+
await AsyncStorage.multiRemove([LEGACY_SESSION_KEY, LEGACY_VISITOR_KEY]);
|
|
83
94
|
|
|
84
95
|
return {
|
|
85
96
|
id: session.id,
|
|
@@ -94,7 +105,7 @@ export async function resolveSession(opts: {
|
|
|
94
105
|
|
|
95
106
|
/** Persist the latest seq + activity so a continued launch resumes correctly. */
|
|
96
107
|
export async function persistProgress(id: string, seq: number, userId?: string): Promise<void> {
|
|
97
|
-
const prior = safeParse<StoredSession>(await
|
|
108
|
+
const prior = safeParse<StoredSession>(await getStored(SESSION_KEY, LEGACY_SESSION_KEY));
|
|
98
109
|
if (!prior || prior.id !== id) return; // a reset() already moved on — don't clobber
|
|
99
110
|
await AsyncStorage.setItem(
|
|
100
111
|
SESSION_KEY,
|
|
@@ -104,5 +115,5 @@ export async function persistProgress(id: string, seq: number, userId?: string):
|
|
|
104
115
|
|
|
105
116
|
/** reset() / logout: drop the continuation record so the next session can't resume this user. */
|
|
106
117
|
export async function clearSession(): Promise<void> {
|
|
107
|
-
await AsyncStorage.
|
|
118
|
+
await AsyncStorage.multiRemove([SESSION_KEY, LEGACY_SESSION_KEY]);
|
|
108
119
|
}
|
|
@@ -61,10 +61,14 @@ export interface Spec extends TurboModule {
|
|
|
61
61
|
stop(): void;
|
|
62
62
|
/** Live-tune cadence/quality/masking without restarting. */
|
|
63
63
|
configure(options: ConfigureOptions): void;
|
|
64
|
-
/** Explicitly mask/unmask a native view by its RN reactTag (powers <
|
|
64
|
+
/** Explicitly mask/unmask a native view by its RN reactTag (powers <ObsrviqMask>). */
|
|
65
65
|
setViewMasked(reactTag: number, masked: boolean): void;
|
|
66
66
|
/** Native-side counters for diagnostics (cheap; async so it never blocks). */
|
|
67
67
|
getDiagnostics(): Promise<NativeDiagnostics>;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
// Prefer the Obsrviq-era native module; fall back to the legacy "LumeraReplay" name so an
|
|
71
|
+
// app still running a pre-rebrand native binary keeps working until it rebuilds. `get`
|
|
72
|
+
// (not `getEnforcing`) returns null when neither is linked — callers guard with try/catch.
|
|
73
|
+
export default (TurboModuleRegistry.get<Spec>('ObsrviqReplay') ??
|
|
74
|
+
TurboModuleRegistry.get<Spec>('LumeraReplay')) as Spec;
|
package/src/transport.ts
CHANGED
|
@@ -28,7 +28,8 @@ export class Transport {
|
|
|
28
28
|
// `application/json` would hit Fastify's built-in JSON parser, leaving the
|
|
29
29
|
// route's Buffer.isBuffer() check false → 400 "empty_body". (Matches the web tracker.)
|
|
30
30
|
'content-type': 'application/octet-stream',
|
|
31
|
-
|
|
31
|
+
// Ingest accepts x-obsrviq-key (primary) and x-lumera-key (legacy). Send the new one.
|
|
32
|
+
'x-obsrviq-key': this.siteKey,
|
|
32
33
|
},
|
|
33
34
|
body: JSON.stringify(batch),
|
|
34
35
|
});
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
package app.lumera.replay
|
|
2
|
-
|
|
3
|
-
import android.view.View
|
|
4
|
-
import com.facebook.react.module.annotations.ReactModule
|
|
5
|
-
import com.facebook.react.uimanager.SimpleViewManager
|
|
6
|
-
import com.facebook.react.uimanager.ThemedReactContext
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Backs the optional `<LumeraMask>` JS component. It is a plain pass-through container
|
|
10
|
-
* (children render normally); its sole job is to tell the capture engine "redact my
|
|
11
|
-
* subtree". On attach it registers its own View.id in [LumeraReplayModule.maskedTags];
|
|
12
|
-
* on detach it removes it. This is the geometry [LumeraReplayModule.collectMaskRects]
|
|
13
|
-
* walks: an explicitly-masked container yields one redaction block over the whole subtree.
|
|
14
|
-
*
|
|
15
|
-
* Kept deliberately minimal — no custom props, no shadow node. JS can also drive masking
|
|
16
|
-
* imperatively via `NativeLumeraReplay.setViewMasked(reactTag, masked)` without this view.
|
|
17
|
-
*/
|
|
18
|
-
@ReactModule(name = LumeraMaskViewManager.REACT_CLASS)
|
|
19
|
-
class LumeraMaskViewManager : SimpleViewManager<View>() {
|
|
20
|
-
|
|
21
|
-
override fun getName() = REACT_CLASS
|
|
22
|
-
|
|
23
|
-
override fun createViewInstance(reactContext: ThemedReactContext): View =
|
|
24
|
-
object : View(reactContext) {
|
|
25
|
-
override fun onAttachedToWindow() {
|
|
26
|
-
super.onAttachedToWindow()
|
|
27
|
-
LumeraReplayModule.maskTag(id)
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
override fun onDetachedFromWindow() {
|
|
31
|
-
LumeraReplayModule.unmaskTag(id)
|
|
32
|
-
super.onDetachedFromWindow()
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
companion object {
|
|
37
|
-
const val REACT_CLASS = "LumeraMask"
|
|
38
|
-
}
|
|
39
|
-
}
|
package/ios/LumeraReplayModule.h
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
#import <Foundation/Foundation.h>
|
|
2
|
-
#import <React/RCTBridgeModule.h>
|
|
3
|
-
|
|
4
|
-
NS_ASSUME_NONNULL_BEGIN
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* The native screenshot-capture TurboModule, exposed to JS as "LumeraReplay".
|
|
8
|
-
*
|
|
9
|
-
* This thin ObjC++ shell exists only to satisfy React Native's codegen'd New-Arch
|
|
10
|
-
* protocol (NativeLumeraReplaySpec, generated from src/spec/NativeLumeraReplay.ts via
|
|
11
|
-
* the `LumeraReplaySpec` codegen target) and to forward each call to the shared Swift
|
|
12
|
-
* engine `LumeraReplay`, which owns the entire capture → mask → encode → upload loop.
|
|
13
|
-
*
|
|
14
|
-
* No capture logic lives here. The implementation (.mm) conforms to the generated
|
|
15
|
-
* <NativeLumeraReplaySpec> protocol and implements `getTurboModule:` for the New Arch.
|
|
16
|
-
*/
|
|
17
|
-
@interface LumeraReplayModule : NSObject <RCTBridgeModule>
|
|
18
|
-
@end
|
|
19
|
-
|
|
20
|
-
NS_ASSUME_NONNULL_END
|
package/src/LumeraMask.tsx
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import { Platform, View, type ViewProps, requireNativeComponent } from 'react-native';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* <LumeraMask> — privacy-by-region wrapper for the native screenshot replay.
|
|
6
|
-
*
|
|
7
|
-
* Wrap any subtree to control whether it is redacted in captured frames:
|
|
8
|
-
*
|
|
9
|
-
* <LumeraMask> …</LumeraMask> // force-mask this subtree
|
|
10
|
-
* <LumeraMask unmask> …</LumeraMask> // force-UNMASK (overrides maskAllText/Images)
|
|
11
|
-
*
|
|
12
|
-
* Under the hood the native view (un)registers its reactTag with the capture engine via
|
|
13
|
-
* `setViewMasked(reactTag, …)`. Masking is composited natively, off the UI thread, during
|
|
14
|
-
* capture — this component renders its children verbatim and adds no per-frame cost.
|
|
15
|
-
*
|
|
16
|
-
* The native view exists on iOS (and Android, task #96). On unsupported platforms /
|
|
17
|
-
* unlinked builds (e.g. Expo Go) it degrades to a plain <View> so layout is unaffected.
|
|
18
|
-
*/
|
|
19
|
-
export interface LumeraMaskProps extends ViewProps {
|
|
20
|
-
/** Force-unmask this subtree (overrides the global maskAllText/maskAllImages defaults). */
|
|
21
|
-
unmask?: boolean;
|
|
22
|
-
children?: React.ReactNode;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
interface NativeMaskProps extends ViewProps {
|
|
26
|
-
masked: boolean;
|
|
27
|
-
children?: React.ReactNode;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// Resolve the native component lazily + defensively: if the native module isn't linked
|
|
31
|
-
// the require throws, and we fall back to a plain View (capture simply won't mask here).
|
|
32
|
-
const NativeLumeraMask: React.ComponentType<NativeMaskProps> | null = (() => {
|
|
33
|
-
if (Platform.OS !== 'ios' && Platform.OS !== 'android') return null;
|
|
34
|
-
try {
|
|
35
|
-
return requireNativeComponent<NativeMaskProps>('LumeraMaskView');
|
|
36
|
-
} catch {
|
|
37
|
-
return null;
|
|
38
|
-
}
|
|
39
|
-
})();
|
|
40
|
-
|
|
41
|
-
export function LumeraMask({ unmask, children, ...rest }: LumeraMaskProps): React.ReactElement {
|
|
42
|
-
if (!NativeLumeraMask) {
|
|
43
|
-
return <View {...rest}>{children}</View>;
|
|
44
|
-
}
|
|
45
|
-
return (
|
|
46
|
-
<NativeLumeraMask masked={!unmask} {...rest}>
|
|
47
|
-
{children}
|
|
48
|
-
</NativeLumeraMask>
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export default LumeraMask;
|