Skip to content

feat: capture requests - in-app screenshots, screen recordings and logs - #46

Merged
boehlerlukas merged 18 commits into
mainfrom
claude/capture-requests
Oct 1, 2026
Merged

boehlerlukas merged 18 commits into
mainfrom
claude/capture-requests

Conversation

@boehlerlukas

@boehlerlukas boehlerlukas commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

What this adds

iOS support for capture requests. When a workflow, an AI agent or a teammate asks for a screenshot or a screen recording, the customer answers from the widget card and the SDK captures the app. Logs requests are collected in the background.

Capture, in the app only, with no prompt

  • No ReplayKit and no new permission prompt. The SDK draws the app's own windows (drawViewHierarchyInRect).
  • Screenshots:
    • every visible window of the foreground scene, in window order;
    • Gleap's own windows and the keyboard are left out;
    • JPEG, long edge ≤ 2560.
  • Recordings: the same window pipeline at an adaptive ~4 fps, encoded with AVAssetWriter as H.264 MP4.
    • Long edge ≤ 1280, letterboxed on rotation, a keyframe every 2 s.
    • Background time is cut out: parts are joined without the gap.
    • Stops cleanly on a memory warning; hard cap 180 s.
  • Masking: secure text fields, one-time-code and card-number fields, and views passed to maskView:, blacked out before anything leaves the device.
    • Masks follow views where they are on screen, also mid-animation. Each mask covers the area the view swept while the window was drawn, plus its final position and 3 pt padding.
    • If the masks can't be placed, the whole window is blacked out.
  • Gleap's own launcher, banners, modals and notifications are left out of captures.
  • Capture bar: a small dark pill that can be dragged anywhere.
    • Ready: grip, then Capture or Start as a pill in the widget colour, then a round ×.
    • Recording: only the red dot, mm:ss / max and a round red Stop.
    • Instruction: a caption above the bar for about 4 s.
    • After a drag: it springs back into the safe area and snaps to the centre. Its position is remembered for the app session and re-fitted on rotation and keyboard changes.
    • Touches outside the pill reach the app.
  • Preview:
    • dark full screen, rounded video, tap to play or pause, thin scrubber;
    • × at the top right, which during an upload cancels it;
    • Retake and Send ➤ pills.
  • Text: every word in the bar and preview comes from the widget's translated capture-start.labels; English is only a fallback.
  • Widget during a capture: minimized, then restored on the conversation, without widgetOpened / widgetClosed callbacks for that.
  • Robustness:
    • Widget open, minimize, restore and close run one after another, each with a watchdog, so a cancel or close mid-animation can't strand the widget.
    • The widget's JavaScript alerts always complete; this fixes a crash with an alert during a capture.
    • If WebKit's web content process dies, the widget page reloads and gets the pending capture back.
    • Leftover capture files are deleted at launch.
    • Every entry point of the capture flow catches exceptions.

Logs requests

  • Taken from the SDK websocket (capture-request) or the ping answer (cr), with caps announced on both.
  • Flow: claim → flush handler (≤ 500 ms) → build the bundle from the existing collectors → gzip → /logs. Once per request id.
  • setRemoteLogCollectionEnabled:NO also stops logs attached to captures.
  • A logs request that fails for network reasons is tried three times (now, +2 min, +8 min), then reported failed.

Public API (Gleap class)

typedef void (^GleapLogFlushHandler)(void (^done)(void));
+ (void)setCaptureEnabled:(BOOL)enabled;             // NO: the widget offers upload only
+ (void)setRemoteLogCollectionEnabled:(BOOL)enabled; // NO: logs requests answered "unsupported"
+ (void)maskView:(UIView *)view;                     // weak reference
+ (void)unmaskView:(UIView *)view;
+ (void)setLogFlushHandler:(nullable GleapLogFlushHandler)handler; // done from any thread, 500 ms cap

Also in this PR

  • Inline video: videos play inline in the widget.
  • Replay frames are kept as small JPEGs (about 13 KB instead of about 12 MB each). The upload format is unchanged.
  • Flutter screenshots: Flutter views are drawn at their real frame.
  • Build: Package.swift and the podspec link libz; the podspec adds AVFoundation, CoreMedia and CoreVideo.
  • Privacy manifest: declares Customer Support data. Please confirm it should be marked linked to the user; the existing entries are "not linked".

Verification

  • Unit tests: 124/124 on iPhone 17 Pro, including a new mask test that fails with the old model-only logic. pod lib lint passes with no warnings. The package and the example app build for iPhone and iPad simulators.
  • iPhone simulator end to end (against a local stub):
    • Screenshot: 1178×2560 with masked views and the secure field black; no keyboard, bar or Gleap button in it.
    • Recording: H.264 High 588×1280, keyframe ~2 s, rotation letterboxed, 6 s auto-stop exactly 6.000 s, background gap removed.
    • Retake, Cancel (bar and preview), capture-cancel, Gleap.close() mid-recording, background/foreground and a simulated memory warning all clean up.
    • Logs over the websocket and the ping answer, including deduplication, expired and invalid ids, and the kill switch.
    • Memory stays flat over 5 cycles.
  • iPad simulator: the bar in portrait and landscape, a 2560×1920 screenshot, a pillarboxed 1280×960 recording, dark mode, German labels.
  • UI round, iPhone and iPad, portrait and landscape:
    • every bar and preview state, with screenshots;
    • drag to a corner, then the next capture starts there;
    • rotation with a remembered position;
    • keyboard up;
    • neither the bar nor the caption is ever in a capture.
  • Stability round, iPhone and iPad simulators:
    • Masks during a stress animation: before the fix, 40/40 screenshots leaked. After it, 0/40 screenshots and 0 of 46–47 recorded frames leak, including a rotation.
    • Cancel at 0–600 ms and close during the minimize and restore animations leave the widget usable.
    • An alert() during a capture: no crash.
    • Killing WebContent mid-capture: the image arrives after the reload.
    • Logs retries end in failed.

Not covered

  • No real devices.
  • Split View / Stage Manager windows smaller than the screen.
  • Black content: video players and DRM content come out black.
  • Not auto-masked: password fields inside Flutter or web views can't be detected (maskView: covers native views). The customer reviews every capture before sending.
  • Snapshot transitions: content inside UIKit's snapshot-based transitions (e.g. a cross-dissolve) has no live view for the masks to follow.
  • [CATransaction flush]: the mask fix calls it up to twice per window per frame. It commits pending UI changes slightly earlier in the run loop; no visible effect was seen.
  • Slow devices: a WebContent reload took about 10 s on the simulator, close to the existing 15 s widget timeout.

Release

Version bump and CHANGELOG at release. The wrappers (React Native, Flutter, Capacitor) need this release; their draft PRs bump the pin.


The whole program: capture requests ("Show me the issue")

Workflows, AI agents (Kai, Kai Resolve, custom agents) and teammates can now ask a customer for a screenshot (with annotation) or a screen recording in the middle of a conversation, and can collect logs (console, network, custom data, environment data, events) from the customer's app in the background.

One server object, the capture request, backs all three. The customer sees a card in the widget; the SDK in their app does the capture; the result lands in the thread and resumes the workflow or wakes the agent. Background log requests travel over the SDK websocket every SDK already keeps open.

How each platform captures:

  • Web, desktop Chrome/Edge: screenshot via one-click "Share this tab" (exact pixels), in-browser render as fallback; recording via tab capture, "Record this page" fallback when sharing is blocked.
  • Web, Firefox/Safari/phones/tablets: in-browser screenshot; window/screen recording on desktop, page recording (rrweb) on phones.
  • iOS / Android apps (incl. React Native, Flutter, Capacitor): in-app capture of the app's own windows: no OS prompt and no new permissions (no ReplayKit, no MediaProjection). Recordings are H.264 MP4 built from frames.
  • Older SDKs, email, WhatsApp & co.: upload / reply-with-attachment fallback, so a request never dead-ends.

Design: https://claude.ai/artifact/5i88BeWfcsWUFhhJmPwK5V (private)

PRs in this program (one per repo):

🤖 Generated with Claude Code

boehlerlukas and others added 16 commits September 30, 2026 21:40
The Flutter capture path drew each top-level view of the key window at its
bounds origin, so every view smaller than the window ended up in the top left
corner of the screenshot. Each view is now drawn at its frame in the window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eenshots

The replay kept up to 60 full-resolution screenshots in memory and never let
go of them (about 12 MB each on a 3x iPhone). Each frame is now encoded right
away, off the main thread, exactly as it is uploaded (half the point size at
1x, JPEG 0.9) and kept as data, around 15 KB per frame. The upload format to
/uploads/sdksteps and the replay payload are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a workflow, an AI agent or a teammate asks the customer for a screenshot
or a screen recording, the Messenger sends capture-start and the SDK captures
the host app itself (Messenger <-> host bridge, contract sections 7, 8 and 10).

- The widget minimizes for the capture: it is dismissed, but its controller,
  WKWebView and page stay alive and connected, it still counts as open, and
  widgetOpened / widgetClosed are not called. It is shown again as it was.
  Closing it while minimized ends the capture and releases the request.
- A capture bar in its own window above the app (never key, touches pass
  through outside the bar, follows the scene's size and orientation, moves
  above the keyboard, can be dragged to the top, VoiceOver labels), with the
  texts from capture-start.labels and English fallbacks.
- Screenshots: every visible window of the foreground scene in window level
  order, without the SDK's own windows and the keyboard, drawn with
  drawViewHierarchyInRect:afterScreenUpdates:NO (Flutter views at their real
  frame), masked views and secure / one-time code / card number text fields
  blacked out, JPEG 0.85 with a long edge of at most 2560 px, sent back as
  capture-image (method frames). Logs for the request follow in the background
  when attachLogs is set.
- Recordings: in-app frame capture behind a recorder protocol (no ReplayKit,
  no system prompt). Frames are drawn at video size straight into pixel
  buffers and encoded on a serial queue: H.264 MP4, long edge at most 1280 px,
  even dimensions fixed at the start (rotation and resizing letterbox), about
  1 Mbit/s, a key frame every 2 s, 4 fps adapting between 2 and 8 to keep the
  main-thread drawing within 30 % of the frame interval, no audio. A red dot,
  mm:ss / max and Stop while recording; it stops at maxDurationSec and on a
  memory warning (keeping what was recorded), pauses while the app is
  inactive (the parts are joined without the gap). A preview with Send /
  Retake / Cancel, then a multipart upload streamed from a file to
  /uploads/attachments with progress, /complete with the file's duration and
  size, and capture-state done once the widget is back.
- Background log bundles for logs requests and attachLogs: /claim, the
  wrapper's flush handler (at most 500 ms), the same collectors and shapes as
  /bugs/v2, gzipped to /logs, once per request.
- New API: setCaptureEnabled:, setRemoteLogCollectionEnabled:, maskView:,
  unmaskView:, setLogFlushHandler:.
- The widget sends capture-capabilities after its ping (frames, no microphone).
- While the widget is minimized the app's console output, page views and
  replay frames count again (they are the app's story), except replay frames
  during a recording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SDK websocket now handles capture-request messages (kind logs) in any
widget state; update actions are still ignored while the widget is open. The
HTTP event ping reads pending logs requests from the answer's cr list, for
when the websocket does not deliver them. Both tell the server what the SDK
can do: caps on the websocket URL and in the ping body (capture.screenshot,
capture.recording, capture.logs; only what is enabled).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Screen recordings and video attachments in the conversation play inline in the
widget, as they already do in banners and modals, instead of taking over the
screen. Playback still needs a tap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sizes

Guards what the server relies on: only plain ids go into request paths, the
logs upload is a valid gzip stream of valid JSON that drops network and then
console logs when it is too large, the recording's multipart body carries the
file unchanged, screenshots and videos stay within their long edge with even
video dimensions, letterboxing after a rotation, and the frame rate keeps the
main-thread drawing within its budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Screenshots and screen recordings the user shares with support for a capture
request (and the chat they belong to) are customer support data, linked to the
user's support conversation, used for app functionality only, no tracking.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Screenshots and recording frames show the screen mid-animation, but the masks
used the model geometry (where an animation ends) and skipped views whose model
alpha was 0. A masked view sliding or fading out, or a password field in a
moving sheet, showed in the capture.

Masks now cover each masked view and secure text field wherever its
presentation layer is between right before and right after the window's
snapshot (Core Animation freezes presentation values for the whole run loop
turn, so a flush starts a fresh transaction around the snapshot), plus its
model rect, with 3 pt padding. A view counts as visible when its model or its
presentation says so. If the masks cannot be placed, the window is blacked out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…of captures

These views live in the app's key window, so a banner or modal shown during a
capture ended up in the screenshot or recording. While one of them is visible,
the window is drawn view by view without them (as Flutter windows already
were); otherwise it is still drawn in one go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
UIKit refuses a presentation or dismissal while another one runs, without
calling its completion. A capture cancelled during the minimize animation
counted the still-dismissing widget as restored and left it off screen but
"open" (Gleap.open did nothing, the launcher stayed hidden); a close during the
restore animation marked it closed while it landed on screen.

The widget manager now runs these transitions in order, each once UIKit has
finished the one before, with watchdogs for refused ones: open checks that the
widget actually landed, minimize only counts once the dismissal finished, a
restore waits for a minimize under way, a close dismisses the widget through
its presenter (alerts over it go too) also when it is restored or still
landing, and waiting transitions a newer one makes pointless are dropped. A
capture that ends while the widget goes down brings it back, and Gleap.open
shows a widget left minimized without a running capture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…erts

During the minimize / restore transitions of a capture UIKit refuses to present
the page's alert() and its completion handler was never called: WebKit then
throws (CompletionHandlerCallChecker) and the app crashed. Closing the widget
while an alert was up did the same.

The handler now runs exactly once: on OK, right away when the alert cannot be
shown (off screen, minimized, on its way in or out, something presented over
the widget, or a refused presentation), or when the alert goes away unanswered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A logs request that failed for network reasons (offline, 408, 429, 5xx) was
tried again on every later delivery, without end. It now gets three tries over
about ten minutes (after 2 and 8 more minutes, deliveries in between are
ignored) and is then answered with an event `failed`
(reason logs-unreachable-<status>), which ends it on the server instead of it
being handed out again. Refused uploads still report `failed` at once; a step
that throws ends the attempt rather than leaving the request marked running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
iOS ends web content processes under memory pressure, likely while the widget
is minimized for a capture; the widget then stayed blank and the capture image
or result sent to it was lost.

The widget now loads its page again (right away when on screen, with the
loading view; otherwise when it is shown again), and after the page's ping it
gets the current request's capture-image and last capture-state once more. A
freshly loaded Messenger fetches the request and opens the editor. What the
Messenger has finished (capture-done, capture-cancel) is not sent again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Recordings and upload bodies of a capture the app did not finish (it was
terminated mid-capture) were only removed when the next capture started. At
the first initialize the SDK now moves tmp/GleapCapture aside and deletes it
(and older leftovers) in the background; no timers or observers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The OSLogStore was created lazily into a nonatomic property. A bug report and a
logs request read the unified log on different threads at the same time and
could both create and assign it. Creation now happens under a lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contract §0.2: a capture failure must never crash the host app. Besides the
widget messages and API calls, these now run inside @try/@catch: taps, drags
and keyboard notifications of the capture bar and preview, the player's
callbacks, the recorder's frame timer, memory warning and scene handlers, the
writer and export completions, the upload delegate, network answers, the logs
bundle's continuations, and the capture manager's timers and callbacks. An
exception in a capture's callback ends that capture as `failed` (released to
the server, widget restored); one in another flow is logged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
boehlerlukas and others added 2 commits October 1, 2026 07:25
…rk recording preview

One look with the web SDK's bar, as asked by Lukas:

Bar (screenshot and recording, every state): one fully rounded dark pill
(#16161A at 97 % over a dark blur, white 10 % hairline, shadow y 12 / blur 40),
sized to its content (at most the screen less 32 pt). A grip of six dots on the
leading edge; the bar drags from the grip or anywhere but its buttons, 1:1 with
the finger from where it was touched, springs back 8 pt inside the safe area on
release, snaps to the horizontal center within 24 pt, and keeps its place
(a point in the safe area) for the next captures of the app's run; rotation,
resizing and the keyboard re-clamp it. Default: bottom center, 16 pt above the
safe area or the keyboard.
- Ready: grip · Capture / Start (pill, widget color, else white) · × (circle).
  The instruction shows as a small caption above (or below) the bar for 4 s or
  until the first drag or tap; VoiceOver reads it when the bar appears.
- Recording: grip · pulsing red dot with mm:ss / max · Stop (red circle).
- Working: a spinner in the main button, same size.
All circles are 36 pt with 44 pt touch targets.

Preview: full screen on #0B0B0C, the video centered with 12 pt rounded corners
and a hairline (tap plays or pauses, a play glyph while paused), a thin
scrubber under it, × top right, the title as a small caption, and Retake and
Send ➤ as pills (full width on iPhone, on the right on iPad). While uploading,
Send shows a spinner and the percentage and Retake and the video wait; × still
cancels the upload.

Every text comes from capture-start.labels (new: barDragHint, previewPlay,
previewPause; barStart now "Start"), with English only as a fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@boehlerlukas
boehlerlukas merged commit 92eeeb2 into main Oct 1, 2026
1 check passed
@boehlerlukas boehlerlukas mentioned this pull request Oct 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant