@captello/ulc-webview-sdk 1.1.0 → 1.3.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,137 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 2e7ba33: The mobile-app channel now has both sides, under names that say who hosts whom.
8
+
9
+ The SDK covers two different embedding directions, and 1.2.0's `/app` entry blurred
10
+ them. They are now named explicitly:
11
+
12
+ | Who embeds whom | Client |
13
+ | --------------------------------------- | ------------------------------------------------------------------------------ |
14
+ | Your page embeds the capture webview | `CaptelloWebview` (unchanged) |
15
+ | The Captello mobile app embeds your app | `MobileHostClient` in your app · **new** `EmbeddedAppClient` in the mobile app |
16
+
17
+ - **`@captello/ulc-webview-sdk/mobile-host`** — `MobileHostClient` replaces 1.2.0's
18
+ `CaptelloAppHost`; `MobileHostRequestType` / `MobileHostResponseType` replace
19
+ `AppRequestType` / `AppResponseType`. Options are `mobileHostWindow` / `embeddedWindow`.
20
+ Behaviour is unchanged.
21
+ - **`@captello/ulc-webview-sdk/embedded-app`** — new. `EmbeddedAppClient(iframe)` is the
22
+ mobile app's side: typed `onRequest(type, listener)` / `onAnyRequest`, and
23
+ `sendAuthToken`, `sendScannerResult`, `sendScannerError`, `sendScannerClosed`, or
24
+ `send(response)` for the rest. Requests are accepted only from the bound iframe and,
25
+ once known, its origin; the target origin is read from `iframe.src` unless given, and
26
+ the client refuses to send with neither rather than fall back to `"*"`.
27
+ - **`@captello/ulc-webview-sdk/app`** (1.2.0) is removed. It shipped hours earlier with no
28
+ consumer, so the rename is not carried as aliases.
29
+
30
+ ## 1.2.0
31
+
32
+ ### Minor Changes
33
+
34
+ - aab3383: New entry `@captello/ulc-webview-sdk/app` for pages that run **inside the Captello mobile
35
+ app** (the meeting platform, Connexions): the page-side client for the app's `postMessage`
36
+ channel, which until now every page hand-rolled.
37
+
38
+ ```ts
39
+ import { CaptelloAppHost } from "@captello/ulc-webview-sdk/app";
40
+
41
+ const app = new CaptelloAppHost();
42
+ const token = await app.requestAuthToken();
43
+ app.notifyReady();
44
+
45
+ const people = await app.openScanner(); // one per scanned person; [] on cancel
46
+ ```
47
+
48
+ - **`openScanner()`** opens the app's badge scanner and resolves once the session ends with
49
+ every person it captured, in scan order — several for a group scan. Each is
50
+ `{ badgeId, fields }`; a badge with no lookup data still arrives with its `badgeId` and
51
+ empty `fields` so it can be linked to the attendee. Rejects with `ScannerError` when the
52
+ app reports a failure. Older app builds that post a single result and no close message
53
+ are handled transparently.
54
+ - **`requestAuthToken()`**, **`notifyReady()`**, **`notifyError()`**, **`navigateBack()`**
55
+ wrap the existing auth and navigation messages; `send()` posts any other `AppRequest`.
56
+ - **`AppRequestType` / `AppResponseType`** enumerate the channel's wire types (plain objects,
57
+ SCREAMING_CASE — unlike the capture-webview channel, which uses JSON strings), with
58
+ `parseAppResponse` and typed `on(type, listener)` subscriptions.
59
+
60
+ - 4d7cdd7: Adds `attach()` — a global entry point that needs no iframe reference, for hosts replacing a
61
+ hand-rolled `window.addEventListener("message", …)` dispatcher:
62
+
63
+ ```js
64
+ const webview = CaptelloSdk.attach({ targetOrigin: CAPTURE_PORTAL_WEB_VIEW_BASE });
65
+ webview.on(CaptelloSdk.OutboundMessageType.FormErrorMessage, (m) => showError(m.data));
66
+ webview.onTranscribeScannerRequest((request, reply) => transcribe(request, reply));
67
+ ```
68
+
69
+ There is nothing to query from the DOM, no ordering to get right relative to when the iframe is
70
+ created, and no teardown to remember.
71
+
72
+ The first inbound message that clears the origin check **and** parses as a well-formed Captello
73
+ message identifies the webview; that sender becomes the target for sends and the window every
74
+ later message is source-matched against, so after the first message this is exactly as strict as
75
+ passing the iframe. Teardown stays automatic: a removed iframe's window reports `closed`, which is
76
+ readable cross-origin, so the client notices its form going away and destroys itself.
77
+
78
+ `new CaptelloWebview(iframe, …)` is unchanged and remains the better choice when you hold the
79
+ element and drive the form unprompted from page load — until the form speaks once, `attach()` has
80
+ no window to post to (sends are buffered by `queueUntilReady` in the meantime).
81
+
82
+ Also adds a Playwright browser-test layer (`pnpm e2e`) that drives the built IIFE bundle against a
83
+ genuinely cross-origin iframe, covering what jsdom cannot: real origin filtering, `event.source`
84
+ identity, and the `closed`-based teardown.
85
+
86
+ - bc9baef: Three additions that let an existing hand-rolled `window.addEventListener("message")`
87
+ integration swap in the SDK without restructuring:
88
+
89
+ - **Transcribe handlers now get a reply handle** as their second argument, so a host that
90
+ can't hand back a promise never has to post a message itself:
91
+
92
+ ```js
93
+ webview.onTranscribeScannerRequest((request, reply) => {
94
+ transcribeService(
95
+ request.image_urls,
96
+ (fields) => reply.resolve({ fields, submissionType: "ocr_transcription" }),
97
+ () => reply.reject("Transcription failed."),
98
+ );
99
+ });
100
+ ```
101
+
102
+ `reply` is a plain `{ resolve, reject, answered }` object that can be passed straight to
103
+ whatever does the work, keeping that code free of any SDK reference. Returning the fields
104
+ still works exactly as before; a handler that returns nothing is taken to own the reply and
105
+ the request stays open until it answers. The first answer wins. Existing single-argument
106
+ handlers are unaffected.
107
+
108
+ - **`resolveTranscribeScannerRequest(request, data)` / `rejectTranscribeScannerRequest(request, error)`** —
109
+ the same two answers on the client, for when the reply happens outside the handler's
110
+ scope (a global bus, a module holding only the `request_id`). Both take the request or
111
+ its bare `request_id`, post immediately (never parked in the ready-queue), and are a
112
+ silent no-op once the client is destroyed or the iframe is detached.
113
+ - **The client tears itself down with its iframe.** The frame argument must be a mounted
114
+ iframe (the caller owns that ordering), and with the new `autoDestroy` option — default
115
+ `true` — the client calls `destroy()` on itself once that element leaves the document,
116
+ removing the `message` listener and dropping every subscription. Removal of an ancestor
117
+ counts, which is the common case: emptying a panel or modal takes the iframe with it.
118
+ Detection uses `MutationObserver`, so it is inert for a `{ contentWindow }` stand-in or
119
+ in a non-DOM environment. Pass `autoDestroy: false` to manage the lifetime yourself.
120
+ - **Every `on*` method now returns a subscription handle** — an object with
121
+ `unsubscribe()`:
122
+
123
+ ```js
124
+ const sub = webview.on(OutboundMessageType.SubmissionBody, save);
125
+ sub.unsubscribe();
126
+ ```
127
+
128
+ The handle is also directly callable, so existing `const off = ...; off()` code keeps
129
+ working unchanged.
130
+
131
+ - **`targetOrigin` accepts a full URL**, reduced to its origin via `new URL(value).origin`.
132
+ Hosts hold a webview base URL or embed URL rather than a bare origin, which never equals
133
+ `event.origin` — the usual reason a hand-rolled origin check ends up commented out.
134
+
3
135
  ## 1.1.0
4
136
 
5
137
  ### Minor Changes
package/README.md CHANGED
@@ -18,6 +18,13 @@ It provides:
18
18
  utilities like `submitForm(iframe)` and `waitForFormLoad(iframe)`.
19
19
  6. **React adapter** (`@captello/ulc-webview-sdk/react`) — a `useCaptelloWebview` hook that
20
20
  manages the client lifecycle and returns an iframe ref plus typed senders.
21
+ 7. **The mobile-app channel** — the SDK's second, separate protocol, for when the
22
+ **Captello mobile app** is the host and embeds an application:
23
+ - `@captello/ulc-webview-sdk/mobile-host` — `MobileHostClient`, used by the _embedded
24
+ application_ (the meeting platform, Connexions) to reach the mobile app for an auth
25
+ token, the badge scanner, and navigation.
26
+ - `@captello/ulc-webview-sdk/embedded-app` — `EmbeddedAppClient`, used by the _mobile
27
+ app_ to serve those requests through the iframe it hosts.
21
28
 
22
29
  The core package is framework-agnostic with no runtime dependencies; React is an
23
30
  optional peer dependency used only by the `/react` entry point.
@@ -28,6 +35,31 @@ optional peer dependency used only by the `/react` entry point.
28
35
  pnpm add @captello/ulc-webview-sdk
29
36
  ```
30
37
 
38
+ ### No bundler? Use the hosted script
39
+
40
+ The webview deployment also serves the SDK as a plain script — a single IIFE
41
+ bundle exposing everything (core + promise helpers, minus the React adapter)
42
+ on a `CaptelloSdk` global. Because it deploys with the webview, the hosted
43
+ file always matches the protocol of the webview at the same origin.
44
+
45
+ ```html
46
+ <script src="https://capture.captello.com/sdk/v1/captello-sdk.js"></script>
47
+ <script>
48
+ const { buildEmbedUrl, CaptelloWebview, FormMode } = CaptelloSdk;
49
+
50
+ const src = buildEmbedUrl("https://capture.captello.com", {
51
+ formId: 1234,
52
+ mode: FormMode.Submit,
53
+ });
54
+ // ... point an iframe at `src` and wire up `new CaptelloWebview(iframe, ...)`
55
+ // exactly as in the quick start below.
56
+ </script>
57
+ ```
58
+
59
+ Use the same origin you embed (`capture.captello.com`, `capture-demo.…`, etc.).
60
+ The `v1` path segment only changes on breaking protocol changes. Teams with a
61
+ bundler should prefer the npm package for types and tree-shaking.
62
+
31
63
  ## Quick start
32
64
 
33
65
  ```ts
@@ -103,6 +135,89 @@ try {
103
135
  }
104
136
  ```
105
137
 
138
+ ### The transcribe button — host-side processing
139
+
140
+ Hosts that can transcribe scanned badges/cards can put a **Transcribe** button in
141
+ the form's badge element: enable it with `showTranscribeButton: true` in the embed
142
+ URL, and answer the requests with `onTranscribeScannerRequest`. The button renders
143
+ inside the badge/barcode element and only while the submission has no email (once
144
+ an email lands — from the badge lookup or from a transcription — it disappears). When pressed, the form sends a
145
+ `transcribe_scanner_request` carrying the scan's `element_id`, its `image_urls`, and
146
+ the `draft_submission_token` the webview was loaded with; the handler's resolved
147
+ fields are sent back and filled into the form (empty values are skipped, so they
148
+ never blank already-filled inputs). Only enable the button when a handler is
149
+ registered — otherwise it times out with an error for the user.
150
+
151
+ ```ts
152
+ const src = buildEmbedUrl("https://capture.captello.com", {
153
+ formId: 1234,
154
+ mode: FormMode.Submit,
155
+ showTranscribeButton: true, // show_transcribe_button=1
156
+ });
157
+
158
+ webview.onTranscribeScannerRequest(async ({ image_urls, draft_submission_token }) => {
159
+ const fields = await transcribeService(image_urls, draft_submission_token);
160
+ // fields: [{ llFieldId: 16, llFieldIdentifier: "FirstName", value: "Patrick" }, ...]
161
+ return { fields, submissionType: "ocr_transcription" };
162
+ });
163
+ ```
164
+
165
+ A thrown `Error`'s message is shown to the user by the form, so throw display-ready
166
+ messages. The wire shapes are exported as `TranscribeScannerRequestMessage`,
167
+ `TranscribeScannerResultMessage`, `TranscribeScannerResultData`, and
168
+ `TranscribedScannerField`. Host processing is a convention, not a one-off: future
169
+ operations get their own `*_request` / `*_result` pairs shaped like this one. In
170
+ React, pass the handler as the `onTranscribeScannerRequest` option/prop instead.
171
+
172
+ #### Answering later, from callback-style code
173
+
174
+ The handler's second argument is a **reply handle** — call it whenever the work
175
+ finishes. Use it when the transcription can't hand back a promise: a callback-style
176
+ AJAX wrapper, an event bus, a method that returns `void`. Nothing else changes, and the
177
+ host never builds or posts a message itself:
178
+
179
+ ```ts
180
+ webview.onTranscribeScannerRequest((request, reply) => {
181
+ ll_ajax_manager.send_request(
182
+ "DraftedSubmissions",
183
+ "transcribeDraftScannerElement",
184
+ { draft_submission_token: request.draft_submission_token, image_urls: request.image_urls },
185
+ (response) =>
186
+ response.success
187
+ ? reply.resolve({ fields: response.fields, submissionType: "ocr_transcription" })
188
+ : reply.reject(response.error),
189
+ () => reply.reject("Transcription failed."),
190
+ );
191
+ });
192
+ ```
193
+
194
+ Because `reply` is just an object with `resolve`/`reject`, you can hand it straight to
195
+ whatever does the work and keep that code free of any SDK reference:
196
+
197
+ ```ts
198
+ webview.onTranscribeScannerRequest((request, reply) => myTranscriber(request, reply));
199
+ ```
200
+
201
+ A handler that returns nothing is taken to own the reply, so the request stays open
202
+ until `reply` answers it — returning the fields and answering through `reply` are both
203
+ first-class, and the **first answer wins** (a second is ignored, with a dev warning).
204
+ `reply.answered` tells you whether one already landed. `reply.reject`'s text is shown
205
+ to the user verbatim, so pass display-ready messages.
206
+
207
+ If the reply happens somewhere with no access to the handler's scope — a global bus, a
208
+ different module holding only the id — the client also exposes the same two answers
209
+ directly, taking the request or its bare `request_id`:
210
+
211
+ ```ts
212
+ webview.resolveTranscribeScannerRequest(request, { fields, submissionType: "ocr_transcription" });
213
+ webview.rejectTranscribeScannerRequest(request.request_id, "Transcription failed.");
214
+ ```
215
+
216
+ Either way the result posts immediately, never sitting in the ready-queue, so a client
217
+ that attached after the form loaded still replies. Answering after `destroy()`, or once
218
+ the iframe is detached, is a silent no-op rather than a throw: by then nobody is
219
+ listening, and the form times its own button out.
220
+
106
221
  ## React — `@captello/ulc-webview-sdk/react`
107
222
 
108
223
  The React adapter is the smoothest way to integrate, in two flavors:
@@ -440,9 +555,19 @@ new CaptelloWebview(iframe, {
440
555
  hostWindow?: Window; // defaults to global window
441
556
  matchSource?: boolean; // default true: only accept messages from this iframe
442
557
  queueUntilReady?: boolean; // default true: buffer sends until form_load_complete
558
+ autoDestroy?: boolean; // default true: destroy() when the iframe leaves the DOM
443
559
  });
444
560
  ```
445
561
 
562
+ Every `on*` method returns a subscription handle — an object with `unsubscribe()`,
563
+ which is also directly callable:
564
+
565
+ ```ts
566
+ const sub = webview.on(OutboundMessageType.SubmissionBody, save);
567
+ sub.unsubscribe(); // preferred
568
+ sub(); // equivalent, for the older off() style
569
+ ```
570
+
446
571
  - `isReady` — `true` once the form has reported `form_load_complete`.
447
572
  - `on(type, listener) => unsubscribe` — subscribe to one outbound type.
448
573
  - `once(type, listener) => unsubscribe` — fire at most once.
@@ -450,8 +575,28 @@ new CaptelloWebview(iframe, {
450
575
  - `submit()`, `reset()`, `updateDraft()`, `triggerValidation(target)` — inbound helpers.
451
576
  - `submitAndWait(timeoutMs?)` — submit and await `submission_body` / `form_error_message` (see above).
452
577
  - `prefill({ submission?, info? })` — pre-fill from a submission body, transcription items, or both.
578
+ - `onTranscribeScannerRequest(handler) => unsubscribe` — answer transcribe requests. The
579
+ handler gets `(request, reply)`: return the fields, or call `reply.resolve(...)` /
580
+ `reply.reject(...)` when callback-style work finishes (see
581
+ [above](#the-transcribe-button--host-side-processing)).
582
+ - `resolveTranscribeScannerRequest(request, data)` / `rejectTranscribeScannerRequest(request, error)`
583
+ — answer from outside the handler's scope. Takes the request or its bare `request_id`.
453
584
  - `send(message)` — low-level escape hatch for any `InboundMessage`.
454
- - `destroy()` — detach the listener and drop subscriptions (idempotent).
585
+ - `destroy()` — detach the listener and drop subscriptions (idempotent; also runs itself when the iframe is removed).
586
+
587
+ **The frame argument** (`FrameLike`) is the `<iframe>` element, or anything exposing a
588
+ `contentWindow`. It must already be mounted — the caller owns that ordering. See
589
+ [The client needs a mounted iframe](#the-client-needs-a-mounted-iframe).
590
+
591
+ **Automatic teardown.** With `autoDestroy` (default `true`), the client calls
592
+ `destroy()` on itself once the iframe leaves the document — including when an ancestor
593
+ is removed — so a torn-down panel can't leak the `message` listener. Set it to `false`
594
+ to manage the lifetime yourself.
595
+
596
+ **`targetOrigin` accepts a full URL**, not just a bare origin: pass the embed URL or your
597
+ webview base URL and the SDK reduces it with `new URL(value).origin`. This is the usual
598
+ reason a hand-rolled origin check gets commented out — a base URL never equals
599
+ `event.origin`.
455
600
 
456
601
  **Send queueing.** With `queueUntilReady` (default `true`), any send before the webview
457
602
  reports `form_load_complete` is buffered and flushed, in order, on load — so calling
@@ -506,6 +651,79 @@ internal listener before settling, including on timeout.
506
651
  > call `waitForFormLoad` (e.g. you attach late), create a long-lived `CaptelloWebview`
507
652
  > before the iframe navigates instead.
508
653
 
654
+ ## The mobile-app channel — `/mobile-host` and `/embedded-app`
655
+
656
+ The SDK covers two embedding directions, and they are different protocols. Keep them apart:
657
+
658
+ | Who embeds whom | Host | Embedded | Client to use | Wire |
659
+ | ------------------------------------------- | ------------------- | --------------------- | ------------------------------------------------------------------------- | ------------------------------- |
660
+ | **Your page embeds the capture webview** | your page | Captello capture form | `CaptelloWebview` (everything above this section) | JSON strings, `snake_case` |
661
+ | **The Captello mobile app embeds your app** | Captello mobile app | your application | `MobileHostClient` (in your app), `EmbeddedAppClient` (in the mobile app) | plain objects, `SCREAMING_CASE` |
662
+
663
+ ### In the embedded application — `MobileHostClient`
664
+
665
+ ```ts
666
+ import { MobileHostClient, ScannerError } from "@captello/ulc-webview-sdk/mobile-host";
667
+
668
+ const mobileHost = new MobileHostClient(); // listens on window, posts to window.parent
669
+
670
+ const token = await mobileHost.requestAuthToken(); // exchange it for a session, then:
671
+ mobileHost.notifyReady(); // the mobile app hides its spinner
672
+
673
+ try {
674
+ const people = await mobileHost.openScanner(); // resolves when the scanner closes
675
+ const [first] = people; // undefined if the user cancelled
676
+ if (first) fillForm(first.fields, first.badgeId);
677
+ } catch (e) {
678
+ if (e instanceof ScannerError) showToast(e.message);
679
+ }
680
+ ```
681
+
682
+ `openScanner()` resolves with every person the scanner captured, in scan order — several
683
+ for a group scan. Each `ScannedPerson` is `{ badgeId, fields }`, where `fields` is a
684
+ `PrefillInfoItem[]` keyed by `ll_field_unique_identifier`, so it can be fed straight into a
685
+ capture form's `prefill({ info })`. A badge with no lookup data still arrives with its
686
+ `badgeId` and empty `fields`. `send(request)` posts any other `MobileHostRequest`;
687
+ `on(MobileHostResponseType.X, listener)` subscribes to any response. `targetOrigin`
688
+ defaults to `"*"` here: the mobile app's webview origin differs per platform and an
689
+ application only uses this channel when it is running inside the app.
690
+
691
+ ### In the mobile app — `EmbeddedAppClient`
692
+
693
+ ```ts
694
+ import { EmbeddedAppClient, MobileHostRequestType } from "@captello/ulc-webview-sdk/embedded-app";
695
+
696
+ const embedded = new EmbeddedAppClient(iframe); // target origin read from iframe.src
697
+
698
+ embedded.onRequest(MobileHostRequestType.RequestAuthToken, async () => {
699
+ embedded.sendAuthToken(await mintMagicToken());
700
+ });
701
+ embedded.onRequest(MobileHostRequestType.OpenScanner, async () => {
702
+ for (const person of await runScanner()) embedded.sendScannerResult(person);
703
+ embedded.sendScannerClosed();
704
+ });
705
+ embedded.onRequest(MobileHostRequestType.NavigateBack, () => modal.dismiss());
706
+
707
+ // when the iframe goes away:
708
+ embedded.destroy();
709
+ ```
710
+
711
+ Requests are only accepted from the bound iframe's window (`matchSource`) and, once a
712
+ target origin is known, from that origin. Responses can carry an auth token, so with no
713
+ `targetOrigin` option and no parsable `iframe.src` the client refuses to send rather than
714
+ fall back to `"*"`.
715
+
716
+ ### Wire reference
717
+
718
+ | Embedded app → mobile app (`MobileHostRequestType`) | `MobileHostClient` | Mobile app → embedded app (`MobileHostResponseType`) | `EmbeddedAppClient` |
719
+ | ------------------------------------------------------------------------------- | ---------------------- | -------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
720
+ | `APP_READY` | `notifyReady()` | — | — |
721
+ | `ERROR { message }` | `notifyError(message)` | — | — |
722
+ | `NAVIGATE_BACK` | `navigateBack()` | — | — |
723
+ | `REQUEST_AUTH_TOKEN` | `requestAuthToken()` | `AUTH_TOKEN { token }` | `sendAuthToken(token)` |
724
+ | `OPEN_ULC_FORM_SCANNER` | `openScanner()` | `ULC_FORM_SCANNER_RESULT { badgeId, result }` ×N, then `ULC_FORM_SCANNER_CLOSED` | `sendScannerResult(person)`, `sendScannerError(msg)`, `sendScannerClosed()` |
725
+ | `OPEN_URL`, `COPY_TEXT`, `SHARE_URL`, `SAVE_VCARD`, `ADD_TO_WALLET`, `SYNC_APP` | `send({ type, ... })` | `COPIED_*`, `SAVE_VCARD_*`, `ADD_TO_WALLET_*` acks | `send({ type })` |
726
+
509
727
  ## Migrating an existing integration
510
728
 
511
729
  Host apps that integrated before this SDK typically hand-rolled the same three pieces:
@@ -549,6 +767,137 @@ const body = await client.submitAndWait(); // throws SubmissionError on form_err
549
767
  Hold the `CaptelloWebview` instance in a ref/context and call `submit()` / `reset()` /
550
768
  `prefill()` instead of re-querying the DOM and stringifying messages by hand.
551
769
 
770
+ ### Worked example: swapping in a global `message` listener
771
+
772
+ The common pre-SDK shape is one listener registered at page setup that JSON-parses every
773
+ event and dispatches on `type`. It swaps in wholesale — with one structural change: the
774
+ client is created where the iframe is created, rather than once at page setup, since it
775
+ needs a mounted iframe. In exchange it tears itself down with that iframe, so there is no
776
+ teardown to remember.
777
+
778
+ ```js
779
+ // before — one global listener, manual parse, if/else chain
780
+ const messageListener = (event) => {
781
+ let dataParsed = {};
782
+ try {
783
+ dataParsed = JSON.parse(event.data);
784
+ } catch (e) {
785
+ return;
786
+ }
787
+ if (dataParsed.type === "form_error_message") {
788
+ show_error_message(dataParsed.data);
789
+ return;
790
+ }
791
+ if (dataParsed.type === "transcribe_scanner_request") {
792
+ ll_form_submits_manager.transcribe_draft_scanner_element(dataParsed);
793
+ return;
794
+ }
795
+ if (dataParsed.type === "submission_body") {
796
+ saveSubmission(dataParsed.data);
797
+ }
798
+ };
799
+ window.addEventListener("message", messageListener);
800
+ ```
801
+
802
+ ```js
803
+ // after — created alongside the iframe; dies with it
804
+ const webview = new CaptelloSdk.CaptelloWebview(panelIframe, {
805
+ targetOrigin: CAPTURE_PORTAL_WEB_VIEW_BASE, // a full URL is fine — reduced to its origin
806
+ });
807
+
808
+ webview.on(CaptelloSdk.OutboundMessageType.FormErrorMessage, (msg) => show_error_message(msg.data));
809
+
810
+ webview.on(CaptelloSdk.OutboundMessageType.TranscribeScannerRequest, (request) =>
811
+ ll_form_submits_manager.transcribe_draft_scanner_element(request),
812
+ );
813
+
814
+ webview.on(CaptelloSdk.OutboundMessageType.SubmissionBody, (msg) => saveSubmission(msg.data));
815
+ ```
816
+
817
+ What you get by dropping the hand-rolled listener: the origin check is enforced (the
818
+ one usually commented out because the constant is a base URL, not a bare origin — the
819
+ SDK reduces it for you), non-Captello and malformed `postMessage` traffic from other
820
+ scripts on the page is filtered out instead of hitting your `try/catch`, unknown `type`
821
+ values are ignored, and `webview.destroy()` removes everything in one call.
822
+
823
+ Where the old code posted a reply by hand, use the client so it is stringified and
824
+ origin-targeted for you — `transcribe_draft_scanner_element` ends with:
825
+
826
+ ```js
827
+ webview.resolveTranscribeScannerRequest(request, { fields, submissionType: "ocr_transcription" });
828
+ // …or, on failure:
829
+ webview.rejectTranscribeScannerRequest(request, "Transcription failed.");
830
+ ```
831
+
832
+ ### `attach()` — no iframe reference needed
833
+
834
+ If your integration is a global `message` listener rather than something that owns the
835
+ iframe element, `attach()` is the whole thing. Call it once, register handlers, done —
836
+ nothing to query from the DOM, no ordering to get right relative to when the iframe is
837
+ created, and no teardown to remember:
838
+
839
+ ```js
840
+ const webview = CaptelloSdk.attach({ targetOrigin: CAPTURE_PORTAL_WEB_VIEW_BASE });
841
+
842
+ webview.on(CaptelloSdk.OutboundMessageType.FormErrorMessage, (m) => showError(m.data));
843
+ webview.on(CaptelloSdk.OutboundMessageType.SubmissionBody, (m) => save(m.data));
844
+ webview.onTranscribeScannerRequest((request, reply) => transcribe(request, reply));
845
+ ```
846
+
847
+ **How it finds the form.** The first inbound message that clears the origin check _and_
848
+ parses as a well-formed Captello message identifies the webview. That sender becomes the
849
+ target for sends and the window every later message is source-matched against — so after
850
+ the first message this is exactly as strict as passing the iframe. With `targetOrigin`
851
+ set to the webview's origin, only the webview can ever be adopted; under the default
852
+ `"*"` that gate is absent, which is one more reason to set it.
853
+
854
+ **Teardown is still automatic.** A removed iframe's window reports `closed`, and `closed`
855
+ is readable cross-origin, so the client notices its form going away and destroys itself.
856
+ (Verified in Chromium against a genuinely cross-origin iframe.) Pass `autoDestroy: false`
857
+ to own the lifetime yourself.
858
+
859
+ **The one trade-off.** Until the form speaks once there is no window to post to, so an
860
+ unprompted `submit()` or `prefill()` before then has nowhere to go. With the default
861
+ `queueUntilReady` those sends are buffered and flushed on `form_load_complete`, which
862
+ covers the normal case. If you drive the form unprompted from page load _and_ can hold
863
+ the element, prefer the explicit form below.
864
+
865
+ ### Or pass the iframe directly
866
+
867
+ Construct the client once the iframe is in the DOM — the caller owns that ordering. The
868
+ client source-matches every inbound message against the iframe's `contentWindow` from
869
+ the very first one, and posts straight to it.
870
+
871
+ If your old listener was registered at page setup, before the panel's iframe existed,
872
+ move client creation to the moment you create the iframe:
873
+
874
+ ```js
875
+ // when the panel opens
876
+ var panelIframe = document.createElement("iframe");
877
+ panelIframe.src = captureUrl;
878
+ panel.appendChild(panelIframe);
879
+
880
+ var webview = new CaptelloSdk.CaptelloWebview(panelIframe, { targetOrigin: BASE });
881
+ webview.on(CaptelloSdk.OutboundMessageType.FormErrorMessage, showError);
882
+ // ...
883
+ ```
884
+
885
+ **You do not need a matching `destroy()`.** The client watches for its iframe leaving
886
+ the document and tears itself down when it does — removing the `message` listener and
887
+ dropping every subscription. An ancestor being removed counts, which is the usual case:
888
+ emptying a panel or modal takes the iframe with it, and the client goes too. Calling
889
+ `destroy()` yourself still works and is idempotent, so belt-and-braces teardown is fine.
890
+
891
+ Pass `autoDestroy: false` to own the lifetime entirely — e.g. if you deliberately detach
892
+ and re-insert the same iframe element and want the client to survive it. Detection uses
893
+ `MutationObserver`, so with a `{ contentWindow }` stand-in or in a non-DOM environment
894
+ it is simply inert.
895
+
896
+ If you'd rather keep your own listener for now, `parseOutboundMessage(event.data)` is
897
+ exported on its own: it replaces the `try { JSON.parse } catch` guard and returns a
898
+ typed message (or `null` for anything that isn't ours), so you can adopt the protocol
899
+ types without restructuring anything.
900
+
552
901
  **Prefill notes for migrators.** `prefill({ info })` items are matched by
553
902
  `ll_field_unique_identifier`; `ll_field_id` is optional (number or string) and `value`
554
903
  may be a string or boolean — so existing payloads with numeric ids and boolean values
@@ -563,6 +912,28 @@ _its_ parent via `window.parent.postMessage` (e.g. relaying `email`/`clientId`,
563
912
  `NAVIGATE_BACK` / scanner messages), that is a separate channel from the capture-form
564
913
  contract — keep that code; this SDK only models host ↔ capture-webview messaging.
565
914
 
915
+ ## Testing
916
+
917
+ Two layers, deliberately split:
918
+
919
+ - **`pnpm test`** — vitest unit tests against a fake window. Fast, covers the client's
920
+ logic, queueing, and every branch of the message protocol.
921
+ - **`pnpm e2e`** — Playwright browser tests that load the **built IIFE bundle** into a real
922
+ page and talk to a real iframe on a **different origin** (`localhost` and `127.0.0.1` on
923
+ one server). This is the only layer that can exercise genuine `postMessage` semantics:
924
+ origin filtering, `event.source` identity, and a removed iframe's `closed` flag — jsdom
925
+ reports `closed` as `undefined`, so `attach()`'s teardown is unprovable without a browser.
926
+ The webview here is a stub, so this proves the client's behaviour, not that both sides
927
+ agree. `e2e/mobile-app-channel.spec.ts` does the same for the mobile-app channel: a stub
928
+ mobile app on one origin (`EmbeddedAppClient`) embeds an application on the other
929
+ (`MobileHostClient`), so both shipped clients are proven against each other.
930
+ - **`e2e/sdk-integration.spec.ts` in the app repo** (`pnpm exec playwright test e2e/sdk-integration.spec.ts`)
931
+ — the SDK against the **real webview**, cross-origin. This is the layer that catches
932
+ protocol drift between the two, because neither side is a stub: it waits for a real
933
+ `form_load_complete`, prefills and asserts the values land in real form inputs, and runs
934
+ `submitAndWait()` through to a real `submission_body` (and, separately, to a real
935
+ validation failure surfacing as `SubmissionError`).
936
+
566
937
  ## Development
567
938
 
568
939
  ```bash
@@ -0,0 +1,60 @@
1
+ // src/mobile-app-protocol.ts
2
+ var MobileHostRequestType = /* @__PURE__ */ ((MobileHostRequestType2) => {
3
+ MobileHostRequestType2["AppReady"] = "APP_READY";
4
+ MobileHostRequestType2["Error"] = "ERROR";
5
+ MobileHostRequestType2["NavigateBack"] = "NAVIGATE_BACK";
6
+ MobileHostRequestType2["RequestAuthToken"] = "REQUEST_AUTH_TOKEN";
7
+ MobileHostRequestType2["OpenScanner"] = "OPEN_ULC_FORM_SCANNER";
8
+ MobileHostRequestType2["OpenUrl"] = "OPEN_URL";
9
+ MobileHostRequestType2["CopyText"] = "COPY_TEXT";
10
+ MobileHostRequestType2["ShareUrl"] = "SHARE_URL";
11
+ MobileHostRequestType2["SaveVCard"] = "SAVE_VCARD";
12
+ MobileHostRequestType2["AddToWallet"] = "ADD_TO_WALLET";
13
+ MobileHostRequestType2["SyncApp"] = "SYNC_APP";
14
+ return MobileHostRequestType2;
15
+ })(MobileHostRequestType || {});
16
+ var MobileHostResponseType = /* @__PURE__ */ ((MobileHostResponseType2) => {
17
+ MobileHostResponseType2["AuthToken"] = "AUTH_TOKEN";
18
+ MobileHostResponseType2["ScannerResult"] = "ULC_FORM_SCANNER_RESULT";
19
+ MobileHostResponseType2["ScannerClosed"] = "ULC_FORM_SCANNER_CLOSED";
20
+ MobileHostResponseType2["CopiedSuccess"] = "COPIED_SUCCESS";
21
+ MobileHostResponseType2["CopiedError"] = "COPIED_ERROR";
22
+ MobileHostResponseType2["SaveVCardSuccess"] = "SAVE_VCARD_SUCCESS";
23
+ MobileHostResponseType2["SaveVCardError"] = "SAVE_VCARD_ERROR";
24
+ MobileHostResponseType2["AddToWalletSuccess"] = "ADD_TO_WALLET_SUCCESS";
25
+ MobileHostResponseType2["AddToWalletError"] = "ADD_TO_WALLET_ERROR";
26
+ return MobileHostResponseType2;
27
+ })(MobileHostResponseType || {});
28
+ var REQUEST_TYPES = new Set(Object.values(MobileHostRequestType));
29
+ var RESPONSE_TYPES = new Set(Object.values(MobileHostResponseType));
30
+ function parseTyped(data, types) {
31
+ let value = data;
32
+ if (typeof value === "string") {
33
+ try {
34
+ value = JSON.parse(value);
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
40
+ const record = value;
41
+ if (typeof record["type"] !== "string" || !types.has(record["type"])) return null;
42
+ return record;
43
+ }
44
+ function parseMobileHostRequest(data) {
45
+ return parseTyped(data, REQUEST_TYPES);
46
+ }
47
+ function parseMobileHostResponse(data) {
48
+ return parseTyped(data, RESPONSE_TYPES);
49
+ }
50
+ function makeSubscription(off) {
51
+ const handle = () => {
52
+ off();
53
+ };
54
+ handle.unsubscribe = off;
55
+ return handle;
56
+ }
57
+
58
+ export { MobileHostRequestType, MobileHostResponseType, makeSubscription, parseMobileHostRequest, parseMobileHostResponse };
59
+ //# sourceMappingURL=chunk-XI5MIDBA.js.map
60
+ //# sourceMappingURL=chunk-XI5MIDBA.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/mobile-app-protocol.ts"],"names":["MobileHostRequestType","MobileHostResponseType"],"mappings":";AA2BO,IAAK,qBAAA,qBAAAA,sBAAAA,KAAL;AAEH,EAAAA,uBAAA,UAAA,CAAA,GAAW,WAAA;AAEX,EAAAA,uBAAA,OAAA,CAAA,GAAQ,OAAA;AAER,EAAAA,uBAAA,cAAA,CAAA,GAAe,eAAA;AAEf,EAAAA,uBAAA,kBAAA,CAAA,GAAmB,oBAAA;AAMnB,EAAAA,uBAAA,aAAA,CAAA,GAAc,uBAAA;AAEd,EAAAA,uBAAA,SAAA,CAAA,GAAU,UAAA;AAEV,EAAAA,uBAAA,UAAA,CAAA,GAAW,WAAA;AAEX,EAAAA,uBAAA,UAAA,CAAA,GAAW,WAAA;AAEX,EAAAA,uBAAA,WAAA,CAAA,GAAY,YAAA;AAEZ,EAAAA,uBAAA,aAAA,CAAA,GAAc,eAAA;AAEd,EAAAA,uBAAA,SAAA,CAAA,GAAU,UAAA;AA1BF,EAAA,OAAAA,sBAAAA;AAAA,CAAA,EAAA,qBAAA,IAAA,EAAA;AAgGL,IAAK,sBAAA,qBAAAC,uBAAAA,KAAL;AAEH,EAAAA,wBAAA,WAAA,CAAA,GAAY,YAAA;AAEZ,EAAAA,wBAAA,eAAA,CAAA,GAAgB,yBAAA;AAEhB,EAAAA,wBAAA,eAAA,CAAA,GAAgB,yBAAA;AAChB,EAAAA,wBAAA,eAAA,CAAA,GAAgB,gBAAA;AAChB,EAAAA,wBAAA,aAAA,CAAA,GAAc,cAAA;AACd,EAAAA,wBAAA,kBAAA,CAAA,GAAmB,oBAAA;AACnB,EAAAA,wBAAA,gBAAA,CAAA,GAAiB,kBAAA;AACjB,EAAAA,wBAAA,oBAAA,CAAA,GAAqB,uBAAA;AACrB,EAAAA,wBAAA,kBAAA,CAAA,GAAmB,qBAAA;AAZX,EAAA,OAAAA,uBAAAA;AAAA,CAAA,EAAA,sBAAA,IAAA,EAAA;AAoFZ,IAAM,gBAAqC,IAAI,GAAA,CAAI,MAAA,CAAO,MAAA,CAAO,qBAAqB,CAAC,CAAA;AACvF,IAAM,iBAAsC,IAAI,GAAA,CAAI,MAAA,CAAO,MAAA,CAAO,sBAAsB,CAAC,CAAA;AAEzF,SAAS,UAAA,CAAW,MAAe,KAAA,EAA4D;AAC3F,EAAA,IAAI,KAAA,GAAiB,IAAA;AACrB,EAAA,IAAI,OAAO,UAAU,QAAA,EAAU;AAC3B,IAAA,IAAI;AACA,MAAA,KAAA,GAAQ,IAAA,CAAK,MAAM,KAAK,CAAA;AAAA,IAC5B,CAAA,CAAA,MAAQ;AACJ,MAAA,OAAO,IAAA;AAAA,IACX;AAAA,EACJ;AACA,EAAA,IAAI,OAAO,UAAU,QAAA,IAAY,KAAA,KAAU,QAAQ,KAAA,CAAM,OAAA,CAAQ,KAAK,CAAA,EAAG,OAAO,IAAA;AAChF,EAAA,MAAM,MAAA,GAAS,KAAA;AACf,EAAA,IAAI,OAAO,MAAA,CAAO,MAAM,CAAA,KAAM,QAAA,IAAY,CAAC,KAAA,CAAM,GAAA,CAAI,MAAA,CAAO,MAAM,CAAC,CAAA,EAAG,OAAO,IAAA;AAC7E,EAAA,OAAO,MAAA;AACX;AAOO,SAAS,uBAAuB,IAAA,EAAyC;AAC5E,EAAA,OAAO,UAAA,CAAW,MAAM,aAAa,CAAA;AACzC;AAOO,SAAS,wBAAwB,IAAA,EAA0C;AAC9E,EAAA,OAAO,UAAA,CAAW,MAAM,cAAc,CAAA;AAC1C;AAGO,SAAS,iBAAiB,GAAA,EAA8B;AAC3D,EAAA,MAAM,SAAU,MAAM;AAClB,IAAA,GAAA,EAAI;AAAA,EACR,CAAA;AACA,EAAA,MAAA,CAAO,WAAA,GAAc,GAAA;AACrB,EAAA,OAAO,MAAA;AACX","file":"chunk-XI5MIDBA.js","sourcesContent":["/**\n * The message protocol between the **Captello mobile app** and an **application it\n * embeds** (the meeting platform, Connexions, …).\n *\n * This is the second of the SDK's two channels, and the roles are the reverse of the\n * capture-webview channel:\n *\n * | Channel | Host | Embedded | Client on each side |\n * | ----------------------------------------- | ------------------- | ------------------- | ---------------------------------------------------- |\n * | A host page embeds the capture webview | your page | capture webview | `CaptelloWebview` (host side) |\n * | The mobile app embeds an application | Captello mobile app | your application | `EmbeddedAppClient` (app side), `MobileHostClient` (embedded side) |\n *\n * Wire format (match it exactly — it differs from the capture-webview channel):\n * - Messages are posted as **plain objects**, not JSON strings.\n * - `type` values are SCREAMING_CASE.\n * - {@link MobileHostRequestType} (embedded application → mobile app) and\n * {@link MobileHostResponseType} (mobile app → embedded application) are disjoint.\n */\n\nimport type { Unsubscribe } from \"./client\";\nimport type { PrefillInfoItem } from \"./messages\";\n\n/* ------------------------------------------------------------------ *\n * Requests: embedded application → mobile app\n * ------------------------------------------------------------------ */\n\n/** Message `type` values an embedded application sends to the Captello mobile app. */\nexport enum MobileHostRequestType {\n /** The application finished loading; the app hides its loading spinner. */\n AppReady = \"APP_READY\",\n /** A user-facing error; the app shows `message`. */\n Error = \"ERROR\",\n /** Close the application (the app dismisses the modal or pops the route). */\n NavigateBack = \"NAVIGATE_BACK\",\n /** Ask for a magic token; answered with {@link MobileHostResponseType.AuthToken}. */\n RequestAuthToken = \"REQUEST_AUTH_TOKEN\",\n /**\n * Open the app's badge scanner. Answered with one\n * {@link MobileHostResponseType.ScannerResult} per scanned person, then\n * {@link MobileHostResponseType.ScannerClosed}.\n */\n OpenScanner = \"OPEN_ULC_FORM_SCANNER\",\n /** Open `url` in the system browser. */\n OpenUrl = \"OPEN_URL\",\n /** Copy `text` to the clipboard; acknowledged with `COPIED_SUCCESS` / `COPIED_ERROR`. */\n CopyText = \"COPY_TEXT\",\n /** Open the native share sheet. */\n ShareUrl = \"SHARE_URL\",\n /** Save a vCard to the contacts; acknowledged with `SAVE_VCARD_SUCCESS` / `SAVE_VCARD_ERROR`. */\n SaveVCard = \"SAVE_VCARD\",\n /** Add a wallet pass; acknowledged with `ADD_TO_WALLET_SUCCESS` / `ADD_TO_WALLET_ERROR`. */\n AddToWallet = \"ADD_TO_WALLET\",\n /** Ask the app to synchronize its data. */\n SyncApp = \"SYNC_APP\",\n}\n\nexport interface AppReadyRequest {\n type: MobileHostRequestType.AppReady;\n}\nexport interface ErrorRequest {\n type: MobileHostRequestType.Error;\n message: string;\n}\nexport interface NavigateBackRequest {\n type: MobileHostRequestType.NavigateBack;\n}\nexport interface RequestAuthTokenRequest {\n type: MobileHostRequestType.RequestAuthToken;\n}\nexport interface OpenScannerRequest {\n type: MobileHostRequestType.OpenScanner;\n}\nexport interface OpenUrlRequest {\n type: MobileHostRequestType.OpenUrl;\n url: string;\n}\nexport interface CopyTextRequest {\n type: MobileHostRequestType.CopyText;\n text: string;\n}\nexport interface ShareUrlRequest {\n type: MobileHostRequestType.ShareUrl;\n title: string;\n text: string;\n}\nexport interface SaveVCardRequest {\n type: MobileHostRequestType.SaveVCard;\n /** vCard text. */\n text: string;\n}\nexport interface AddToWalletRequest {\n type: MobileHostRequestType.AddToWallet;\n /** Base64 pass data, with or without a `data:` prefix. */\n text: string;\n}\nexport interface SyncAppRequest {\n type: MobileHostRequestType.SyncApp;\n}\n\n/** Discriminated union of every message an embedded application can send to the mobile app. */\nexport type MobileHostRequest =\n | AppReadyRequest\n | ErrorRequest\n | NavigateBackRequest\n | RequestAuthTokenRequest\n | OpenScannerRequest\n | OpenUrlRequest\n | CopyTextRequest\n | ShareUrlRequest\n | SaveVCardRequest\n | AddToWalletRequest\n | SyncAppRequest;\n\n/** Maps each request `type` to its full message shape (used by {@link EmbeddedAppClient.onRequest}). */\nexport type MobileHostRequestMap = {\n [M in MobileHostRequest as M[\"type\"]]: M;\n};\n\n/* ------------------------------------------------------------------ *\n * Responses: mobile app → embedded application\n * ------------------------------------------------------------------ */\n\n/** Message `type` values the Captello mobile app sends to an embedded application. */\nexport enum MobileHostResponseType {\n /** Answer to {@link MobileHostRequestType.RequestAuthToken}. */\n AuthToken = \"AUTH_TOKEN\",\n /** One scanned person (or a scanner failure) after {@link MobileHostRequestType.OpenScanner}. */\n ScannerResult = \"ULC_FORM_SCANNER_RESULT\",\n /** The scanner session ended: every result was posted, or the user cancelled. */\n ScannerClosed = \"ULC_FORM_SCANNER_CLOSED\",\n CopiedSuccess = \"COPIED_SUCCESS\",\n CopiedError = \"COPIED_ERROR\",\n SaveVCardSuccess = \"SAVE_VCARD_SUCCESS\",\n SaveVCardError = \"SAVE_VCARD_ERROR\",\n AddToWalletSuccess = \"ADD_TO_WALLET_SUCCESS\",\n AddToWalletError = \"ADD_TO_WALLET_ERROR\",\n}\n\n/** Mobile app → embedded application: the magic token requested with {@link MobileHostRequestType.RequestAuthToken}. */\nexport interface AuthTokenMessage {\n type: MobileHostResponseType.AuthToken;\n token: string;\n}\n\n/**\n * Mobile app → embedded application: one scanned person, or a scanner failure.\n *\n * The app posts one of these per person the scanner captured — several for a group\n * scan — then a {@link ScannerClosedMessage}. `result` holds the looked-up attendee\n * fields in the same shape {@link PrefillInfoItem} uses, so they can be fed straight to\n * a capture form's `prefill({ info })`. It is empty (not absent) when the badge had no\n * lookup data; the badge can still be linked via `badgeId`.\n *\n * On failure `result` is absent and `message` carries the error text.\n */\nexport interface ScannerResultMessage {\n type: MobileHostResponseType.ScannerResult;\n /**\n * Badge ID the person was scanned from. Empty for business cards and manual search.\n * Absent altogether on app builds that predate multi-person sessions — those post a\n * single result and never a {@link ScannerClosedMessage}.\n */\n badgeId?: string;\n /** Looked-up attendee fields (success). */\n result?: PrefillInfoItem[];\n /** Error text (failure) — `result` is absent. */\n message?: string;\n}\n\n/** Mobile app → embedded application: the scanner session ended. Follows the last {@link ScannerResultMessage}. */\nexport interface ScannerClosedMessage {\n type: MobileHostResponseType.ScannerClosed;\n}\n\ninterface AckMessage<T extends MobileHostResponseType> {\n type: T;\n}\n\n/** Discriminated union of every message the mobile app can send to an embedded application. */\nexport type MobileHostResponse =\n | AuthTokenMessage\n | ScannerResultMessage\n | ScannerClosedMessage\n | AckMessage<MobileHostResponseType.CopiedSuccess>\n | AckMessage<MobileHostResponseType.CopiedError>\n | AckMessage<MobileHostResponseType.SaveVCardSuccess>\n | AckMessage<MobileHostResponseType.SaveVCardError>\n | AckMessage<MobileHostResponseType.AddToWalletSuccess>\n | AckMessage<MobileHostResponseType.AddToWalletError>;\n\n/** Maps each response `type` to its full message shape (used by {@link MobileHostClient.on}). */\nexport type MobileHostResponseMap = {\n [M in MobileHostResponse as M[\"type\"]]: M;\n};\n\n/** One person captured by the mobile app's scanner. */\nexport interface ScannedPerson {\n /** Badge ID the person was scanned from; empty for business cards and manual search. */\n badgeId: string;\n /** Looked-up attendee fields; empty when the badge had no lookup data. */\n fields: PrefillInfoItem[];\n}\n\n/* ------------------------------------------------------------------ *\n * Runtime guards / parsing\n * ------------------------------------------------------------------ */\n\nconst REQUEST_TYPES: ReadonlySet<string> = new Set(Object.values(MobileHostRequestType));\nconst RESPONSE_TYPES: ReadonlySet<string> = new Set(Object.values(MobileHostResponseType));\n\nfunction parseTyped(data: unknown, types: ReadonlySet<string>): Record<string, unknown> | null {\n let value: unknown = data;\n if (typeof value === \"string\") {\n try {\n value = JSON.parse(value);\n } catch {\n return null;\n }\n }\n if (typeof value !== \"object\" || value === null || Array.isArray(value)) return null;\n const record = value as Record<string, unknown>;\n if (typeof record[\"type\"] !== \"string\" || !types.has(record[\"type\"])) return null;\n return record;\n}\n\n/**\n * Parses a raw `MessageEvent.data` value into a typed {@link MobileHostRequest}, or\n * returns `null` if it is not a recognized request. Plain objects are the wire format;\n * a JSON string is accepted too for robustness.\n */\nexport function parseMobileHostRequest(data: unknown): MobileHostRequest | null {\n return parseTyped(data, REQUEST_TYPES) as unknown as MobileHostRequest | null;\n}\n\n/**\n * Parses a raw `MessageEvent.data` value into a typed {@link MobileHostResponse}, or\n * returns `null` if it is not a recognized response. Plain objects are the wire format;\n * a JSON string is accepted too for robustness.\n */\nexport function parseMobileHostResponse(data: unknown): MobileHostResponse | null {\n return parseTyped(data, RESPONSE_TYPES) as unknown as MobileHostResponse | null;\n}\n\n/** @internal Builds the callable-with-`unsubscribe()` handle every `on*` method returns. */\nexport function makeSubscription(off: () => void): Unsubscribe {\n const handle = (() => {\n off();\n }) as Unsubscribe;\n handle.unsubscribe = off;\n return handle;\n}\n"]}