@lumen-stack/capacitor
v0.3.0
Published
Capacitor plugin for Lumen — real native screenshots, screen recordings, shake-to-open and keyboard handling for the feedback widget.
Downloads
1,055
Readme
@lumen-stack/capacitor
Native Capacitor plugin for the Lumen feedback widget. Inside a Capacitor app it replaces the DOM screenshot (html2canvas) with real screenshots and adds native screen recording, shake-to-open and keyboard handling on iOS and Android.
| | iOS | Android |
|---|---|---|
| Screenshot | drawHierarchy of every visible window in the scene (WebView, native views, video, keyboard, alerts) | PixelCopy of the window surface (API 26+), View.draw fallback |
| Screen recording | ReplayKit in-app capture → H.264 MP4 (no broadcast extension); the keyboard is drawn in, see below | MediaProjection + foreground service → H.264 MP4 |
| Consent | iOS system prompt, once per app | System dialog every recording (Android 14+: "single app" option) |
| Shake to open | CoreMotion, only while enabled | Accelerometer, only while enabled and app in foreground |
Requires Capacitor 6, 7 or 8.
What a recording contains
iOS draws the system keyboard in its own window, and in-app ReplayKit capture only gets the app's windows, so the real keyboard is never in the clip. The plugin tracks the keyboard frame and paints a dark KEYBOARD panel at that position into every frame, so "the keyboard slides over the input" is visible. On Android the keyboard is captured as is.
On both platforms the widget also logs taps, focus, typing (never the value), the keyboard, scrolling and page changes while recording. Taps show up in the clip as a short orange ring. The log is sent with the clip and used by the video analysis.
Install
npm install @lumen-stack/capacitor @lumen-stack/react
npx cap syncThat's it. <LumenProvider> detects the plugin at runtime and routes capture, recording, shake and keyboard through it. No extra code in your app:
import { LumenProvider } from "@lumen-stack/react";
<LumenProvider apiKey="lk_pub_…">{children}</LumenProvider>In the browser (and npx cap run on the web platform) the same code keeps using DOM screenshots and getDisplayMedia.
Or let the CLI do it: npx @lumen-stack/cli init detects Capacitor, installs both packages and runs npx cap sync.
Updating
Update the Lumen packages together, sync, and rebuild the native app:
npm install @lumen-stack/react@latest @lumen-stack/capacitor@latest --prefer-online
npx cap sync
npx cap run ios # and/or android — or build from Xcode / Android Studio(npx @lumen-stack/cli init does the install + sync for you; re-running it upgrades.) A live-update / OTA bundle only carries the JS: the plugin's native code reaches users with a new native build. If the plugin is missing from the native build, Lumen falls back to DOM screenshots and logs [lumen] Native Capacitor … app detected, but the @lumen-stack/capacitor plugin is missing — the feedback's capture warnings in the dashboard say the same.
Allowed origins
Add your app's WebView origin under Settings → Allowed origins in the Lumen dashboard:
- iOS:
capacitor://localhost - Android:
https://localhost
(Use your own values if you changed server.hostname / server.iosScheme / server.androidScheme.)
iOS
- CocoaPods and Swift Package Manager are both supported;
npx cap syncpicks whichever your project uses. - For recordings with microphone audio, add
NSMicrophoneUsageDescriptiontoInfo.plist.
Android
The plugin's manifest adds what MediaProjection needs (merged automatically):
FOREGROUND_SERVICE,FOREGROUND_SERVICE_MEDIA_PROJECTION- a
mediaProjectionforeground service that shows a "Recording screen" notification while recording RECORD_AUDIO(voice notes, optional mic in recordings)
Recording stops automatically when the app goes to the background, so nothing outside your app ends up in the clip.
Google Play: apps using the
mediaProjectionforeground service type must declare it in the Play Console (App content → Foreground service permissions). Describe it as "user-initiated screen recording to attach to a bug report".
Options
Everything is automatic. To opt out and keep web-only behavior:
<LumenProvider apiKey="…" nativeBridge={false} />To tune the native host, pass the detectCapacitorHost options (below) instead of true:
<LumenProvider apiKey="…" nativeBridge={{ screenshotStatusBar: "synthetic" }} />An explicit capture={{ provider }}, capture={{ mode: "dom" }} or record={{ provider }} also wins over the plugin.
For direct access (e.g. your own UI):
import { LumenCapacitor, detectCapacitorHost } from "@lumen-stack/capacitor";
const host = detectCapacitorHost({ screenshotFormat: "png", withMicrophone: true });
<LumenProvider capture={{ mode: "custom", provider: host?.captureProvider }} record={host?.record} />detectCapacitorHost(options):
| Option | Default | |
|---|---|---|
| screenshotFormat | "jpeg" | "png" for lossless; JPEG is ~5–10× smaller and opens faster |
| screenshotQuality | 0.9 | JPEG quality 0–1 |
| withMicrophone | false | Record mic audio with the screen (Android: only if RECORD_AUDIO is already granted) |
| screenshotStatusBar | "none" | iOS only. "synthetic" paints a reconstructed status bar (time, battery) into screenshots. See below |
iOS status bar in screenshots
iOS draws the status bar (time, battery, notch area) outside the app's windows, and no public API lets an app capture it. Screenshots therefore show the app with an empty strip at the top. With screenshotStatusBar: "synthetic", Lumen paints the current time and battery level there, in the status bar's light/dark style, so the screenshot looks like what the user saw. It is a reconstruction, not a capture, and the feedback's capture notes in the dashboard say so. Signal and Wi-Fi are left out, because the app can't read them reliably.
A single ReplayKit frame (RPScreenRecorder.startCapture) would need the recording consent prompt for every screenshot, so Lumen does not use it for screenshots.
How recording transfers
The native side records into a temp file. When the recording stops, JS pulls the file in 512 KB slices (readRecording) and then deletes it (discardRecording). There is no single multi-MB message over the bridge and no event that has to arrive in time. Leftover files from a crash are removed on the next app start.
Native plugin API
| Method | Returns |
|---|---|
| captureScreen({ format?, quality?, statusBar? }) | { dataUrl, pixelRatio, statusBar? }. statusBar: "synthetic" (iOS) paints a reconstructed status bar; the result says whether it was drawn |
| isRecordingAvailable() | { available } |
| startRecording({ maxDurationMs, withMicrophone }) | { recordingId }, after consent and once frames flow. Rejects with code: permission_denied, already_recording, unavailable, start_failed |
| stopRecording({ recordingId }) | { recordingId, size, durationMs, mimeType, posterDataUrl? }. iOS adds a natively decoded first frame (JPEG) that the widget uses as the preview |
| readRecording({ recordingId, offset, length }) | { data } (base64) |
| discardRecording({ recordingId }) / cancelRecording({ recordingId }) | – |
| setShakeEnabled({ enabled }) | – |
| Event | Payload |
|---|---|
| lumenRecordingStopped | { recordingId, reason: "maxDuration" \| "background" \| "system" } |
| lumenShake | {} |
| lumenKeyboardChange | { height, keyboardHeight, visible }: height is the CSS px of the WebView covered by the keyboard (0 if the WebView resizes), keyboardHeight the full keyboard height in CSS px |
