@captello/ulc-webview-sdk 1.2.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 +27 -0
- package/README.md +61 -30
- package/dist/chunk-XI5MIDBA.js +60 -0
- package/dist/chunk-XI5MIDBA.js.map +1 -0
- package/dist/embedded-app.d.ts +93 -0
- package/dist/embedded-app.js +119 -0
- package/dist/embedded-app.js.map +1 -0
- package/dist/mobile-app-protocol-Bt-UDbu5.d.ts +176 -0
- package/dist/mobile-host.d.ts +85 -0
- package/dist/mobile-host.js +123 -0
- package/dist/mobile-host.js.map +1 -0
- package/dist/react.js.map +1 -1
- package/package.json +13 -6
- package/dist/app-host.d.ts +0 -228
- package/dist/app-host.js +0 -170
- package/dist/app-host.js.map +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
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
|
+
|
|
3
30
|
## 1.2.0
|
|
4
31
|
|
|
5
32
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -18,9 +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. **
|
|
22
|
-
Captello mobile app
|
|
23
|
-
|
|
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.
|
|
24
28
|
|
|
25
29
|
The core package is framework-agnostic with no runtime dependencies; React is an
|
|
26
30
|
optional peer dependency used only by the `/react` entry point.
|
|
@@ -647,23 +651,27 @@ internal listener before settling, including on timeout.
|
|
|
647
651
|
> call `waitForFormLoad` (e.g. you attach late), create a long-lived `CaptelloWebview`
|
|
648
652
|
> before the iframe navigates instead.
|
|
649
653
|
|
|
650
|
-
##
|
|
654
|
+
## The mobile-app channel — `/mobile-host` and `/embedded-app`
|
|
651
655
|
|
|
652
|
-
The
|
|
653
|
-
|
|
654
|
-
|
|
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`
|
|
656
664
|
|
|
657
665
|
```ts
|
|
658
|
-
import {
|
|
666
|
+
import { MobileHostClient, ScannerError } from "@captello/ulc-webview-sdk/mobile-host";
|
|
659
667
|
|
|
660
|
-
const
|
|
668
|
+
const mobileHost = new MobileHostClient(); // listens on window, posts to window.parent
|
|
661
669
|
|
|
662
|
-
const token = await
|
|
663
|
-
|
|
670
|
+
const token = await mobileHost.requestAuthToken(); // exchange it for a session, then:
|
|
671
|
+
mobileHost.notifyReady(); // the mobile app hides its spinner
|
|
664
672
|
|
|
665
673
|
try {
|
|
666
|
-
const people = await
|
|
674
|
+
const people = await mobileHost.openScanner(); // resolves when the scanner closes
|
|
667
675
|
const [first] = people; // undefined if the user cancelled
|
|
668
676
|
if (first) fillForm(first.fields, first.badgeId);
|
|
669
677
|
} catch (e) {
|
|
@@ -675,24 +683,46 @@ try {
|
|
|
675
683
|
for a group scan. Each `ScannedPerson` is `{ badgeId, fields }`, where `fields` is a
|
|
676
684
|
`PrefillInfoItem[]` keyed by `ll_field_unique_identifier`, so it can be fed straight into a
|
|
677
685
|
capture form's `prefill({ info })`. A badge with no lookup data still arrives with its
|
|
678
|
-
`badgeId` and empty `fields`.
|
|
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
|
+
```
|
|
679
710
|
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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 `"*"`.
|
|
684
715
|
|
|
685
|
-
|
|
686
|
-
| ------------------------------------------------------------------------------- | ---------------------- | -------------------------------------------------------------------------------- |
|
|
687
|
-
| `APP_READY` | `notifyReady()` | — |
|
|
688
|
-
| `ERROR` | `notifyError(message)` | — |
|
|
689
|
-
| `NAVIGATE_BACK` | `navigateBack()` | — |
|
|
690
|
-
| `REQUEST_AUTH_TOKEN` | `requestAuthToken()` | `AUTH_TOKEN { token }` |
|
|
691
|
-
| `OPEN_ULC_FORM_SCANNER` | `openScanner()` | `ULC_FORM_SCANNER_RESULT { badgeId, result }` ×N, then `ULC_FORM_SCANNER_CLOSED` |
|
|
692
|
-
| `OPEN_URL`, `COPY_TEXT`, `SHARE_URL`, `SAVE_VCARD`, `ADD_TO_WALLET`, `SYNC_APP` | `send({ type, ... })` | `COPIED_*`, `SAVE_VCARD_*`, `ADD_TO_WALLET_*` acks |
|
|
716
|
+
### Wire reference
|
|
693
717
|
|
|
694
|
-
|
|
695
|
-
|
|
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 })` |
|
|
696
726
|
|
|
697
727
|
## Migrating an existing integration
|
|
698
728
|
|
|
@@ -894,8 +924,9 @@ Two layers, deliberately split:
|
|
|
894
924
|
origin filtering, `event.source` identity, and a removed iframe's `closed` flag — jsdom
|
|
895
925
|
reports `closed` as `undefined`, so `attach()`'s teardown is unprovable without a browser.
|
|
896
926
|
The webview here is a stub, so this proves the client's behaviour, not that both sides
|
|
897
|
-
agree. `e2e/app-
|
|
898
|
-
origin embeds
|
|
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.
|
|
899
930
|
- **`e2e/sdk-integration.spec.ts` in the app repo** (`pnpm exec playwright test e2e/sdk-integration.spec.ts`)
|
|
900
931
|
— the SDK against the **real webview**, cross-origin. This is the layer that catches
|
|
901
932
|
protocol drift between the two, because neither side is a stub: it waits for a real
|
|
@@ -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"]}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { F as FrameLike, U as Unsubscribe } from './client-CAMlFA8s.js';
|
|
2
|
+
import { M as MobileHostRequest, a as MobileHostRequestType, b as MobileHostRequestMap, c as MobileHostResponse, S as ScannedPerson } from './mobile-app-protocol-Bt-UDbu5.js';
|
|
3
|
+
export { A as AuthTokenMessage, d as MobileHostResponseMap, e as MobileHostResponseType, f as ScannerClosedMessage, g as ScannerResultMessage, p as parseMobileHostRequest, h as parseMobileHostResponse } from './mobile-app-protocol-Bt-UDbu5.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `@captello/ulc-webview-sdk/embedded-app` — for the **Captello mobile app** (or any
|
|
7
|
+
* host playing its role) that embeds an application in an iframe and serves it native
|
|
8
|
+
* services over `postMessage`.
|
|
9
|
+
*
|
|
10
|
+
* {@link EmbeddedAppClient} wraps the iframe: it delivers the embedded application's
|
|
11
|
+
* requests to typed handlers and posts the app's responses back. The embedded
|
|
12
|
+
* application's own side is `MobileHostClient` (`@captello/ulc-webview-sdk/mobile-host`).
|
|
13
|
+
*
|
|
14
|
+
* Not to be confused with `CaptelloWebview`, which wraps an iframe of the *capture
|
|
15
|
+
* webview* — a different protocol (JSON strings, snake_case types).
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* import { EmbeddedAppClient, MobileHostRequestType } from "@captello/ulc-webview-sdk/embedded-app";
|
|
19
|
+
*
|
|
20
|
+
* const embedded = new EmbeddedAppClient(iframe, { targetOrigin: new URL(iframe.src).origin });
|
|
21
|
+
* embedded.onRequest(MobileHostRequestType.RequestAuthToken, async () => {
|
|
22
|
+
* embedded.sendAuthToken(await mintMagicToken());
|
|
23
|
+
* });
|
|
24
|
+
* embedded.onRequest(MobileHostRequestType.OpenScanner, async () => {
|
|
25
|
+
* for (const person of await scan()) embedded.sendScannerResult(person);
|
|
26
|
+
* embedded.sendScannerClosed();
|
|
27
|
+
* });
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** Listener for a specific request type from the embedded application. */
|
|
31
|
+
type MobileHostRequestListener<T extends MobileHostRequestType> = (request: MobileHostRequestMap[T]) => void;
|
|
32
|
+
/** Listener for every request from the embedded application (used by {@link EmbeddedAppClient.onAnyRequest}). */
|
|
33
|
+
type AnyMobileHostRequestListener = (request: MobileHostRequest) => void;
|
|
34
|
+
interface EmbeddedAppClientOptions {
|
|
35
|
+
/**
|
|
36
|
+
* Origin to validate incoming requests against and to target outgoing responses.
|
|
37
|
+
* A full URL is accepted and reduced to its origin.
|
|
38
|
+
*
|
|
39
|
+
* Defaults to the iframe's `src` origin, read fresh on every send so a `src` bound
|
|
40
|
+
* after construction still works. When neither is available the client refuses to
|
|
41
|
+
* send rather than fall back to `"*"` — a response can carry an auth token, and
|
|
42
|
+
* `"*"` would hand it to whatever the frame navigated to.
|
|
43
|
+
*/
|
|
44
|
+
targetOrigin?: string;
|
|
45
|
+
/** Window to listen on. Defaults to the global `window`. */
|
|
46
|
+
hostWindow?: Window;
|
|
47
|
+
/**
|
|
48
|
+
* Only accept requests whose `event.source` is the bound iframe's window. Defaults
|
|
49
|
+
* to `true`. Set `false` if the application relays through another window.
|
|
50
|
+
*/
|
|
51
|
+
matchSource?: boolean;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Mobile-app-side client for an application embedded in an iframe.
|
|
55
|
+
*
|
|
56
|
+
* Attaches a single `message` listener on construction; call {@link destroy} when the
|
|
57
|
+
* iframe goes away.
|
|
58
|
+
*/
|
|
59
|
+
declare class EmbeddedAppClient {
|
|
60
|
+
private readonly frame;
|
|
61
|
+
private readonly hostWindow;
|
|
62
|
+
private readonly explicitOrigin;
|
|
63
|
+
private readonly matchSource;
|
|
64
|
+
private readonly listeners;
|
|
65
|
+
private readonly anyListeners;
|
|
66
|
+
private readonly boundHandler;
|
|
67
|
+
private destroyed;
|
|
68
|
+
constructor(frame: FrameLike, options?: EmbeddedAppClientOptions);
|
|
69
|
+
/** Subscribe to one request type. Returns a handle with `unsubscribe()` (also callable). */
|
|
70
|
+
onRequest<T extends MobileHostRequestType>(type: T, listener: MobileHostRequestListener<T>): Unsubscribe;
|
|
71
|
+
/** Subscribe to every request. */
|
|
72
|
+
onAnyRequest(listener: AnyMobileHostRequestListener): Unsubscribe;
|
|
73
|
+
/**
|
|
74
|
+
* Post a response into the embedded application. Returns `false` when nothing was
|
|
75
|
+
* sent: the client is destroyed, the iframe has no window, or no target origin could
|
|
76
|
+
* be resolved (see {@link EmbeddedAppClientOptions.targetOrigin}).
|
|
77
|
+
*/
|
|
78
|
+
send(response: MobileHostResponse): boolean;
|
|
79
|
+
/** Answer a {@link MobileHostRequestType.RequestAuthToken} request. */
|
|
80
|
+
sendAuthToken(token: string): boolean;
|
|
81
|
+
/** Post one scanned person of the current scanner session. */
|
|
82
|
+
sendScannerResult(person: ScannedPerson): boolean;
|
|
83
|
+
/** Fail the current scanner session with a display-ready message. */
|
|
84
|
+
sendScannerError(message: string): boolean;
|
|
85
|
+
/** End the current scanner session; send after the last result, or on cancel. */
|
|
86
|
+
sendScannerClosed(): boolean;
|
|
87
|
+
/** Remove the `message` listener and drop every subscription. Safe to call more than once. */
|
|
88
|
+
destroy(): void;
|
|
89
|
+
private resolveOrigin;
|
|
90
|
+
private handleMessage;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export { type AnyMobileHostRequestListener, EmbeddedAppClient, type EmbeddedAppClientOptions, FrameLike, MobileHostRequest, type MobileHostRequestListener, MobileHostRequestMap, MobileHostRequestType, MobileHostResponse, ScannedPerson };
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { makeSubscription, parseMobileHostRequest } from './chunk-XI5MIDBA.js';
|
|
2
|
+
export { MobileHostRequestType, MobileHostResponseType, parseMobileHostRequest, parseMobileHostResponse } from './chunk-XI5MIDBA.js';
|
|
3
|
+
|
|
4
|
+
// src/embedded-app.ts
|
|
5
|
+
function toOrigin(value) {
|
|
6
|
+
if (!value) return void 0;
|
|
7
|
+
if (value === "*") return value;
|
|
8
|
+
try {
|
|
9
|
+
return new URL(value).origin;
|
|
10
|
+
} catch {
|
|
11
|
+
return void 0;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
var EmbeddedAppClient = class {
|
|
15
|
+
constructor(frame, options = {}) {
|
|
16
|
+
this.listeners = /* @__PURE__ */ new Map();
|
|
17
|
+
this.anyListeners = /* @__PURE__ */ new Set();
|
|
18
|
+
this.boundHandler = (event) => this.handleMessage(event);
|
|
19
|
+
this.destroyed = false;
|
|
20
|
+
if (!frame) {
|
|
21
|
+
throw new Error("EmbeddedAppClient: a mounted iframe element (or { contentWindow }) is required.");
|
|
22
|
+
}
|
|
23
|
+
const hostWindow = options.hostWindow ?? (typeof window !== "undefined" ? window : void 0);
|
|
24
|
+
if (!hostWindow) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
"EmbeddedAppClient: no host window available. Pass `hostWindow` when constructing outside a browser."
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
this.frame = frame;
|
|
30
|
+
this.hostWindow = hostWindow;
|
|
31
|
+
this.explicitOrigin = toOrigin(options.targetOrigin);
|
|
32
|
+
this.matchSource = options.matchSource ?? true;
|
|
33
|
+
this.hostWindow.addEventListener("message", this.boundHandler);
|
|
34
|
+
}
|
|
35
|
+
/** Subscribe to one request type. Returns a handle with `unsubscribe()` (also callable). */
|
|
36
|
+
onRequest(type, listener) {
|
|
37
|
+
let set = this.listeners.get(type);
|
|
38
|
+
if (!set) {
|
|
39
|
+
set = /* @__PURE__ */ new Set();
|
|
40
|
+
this.listeners.set(type, set);
|
|
41
|
+
}
|
|
42
|
+
set.add(listener);
|
|
43
|
+
return makeSubscription(() => {
|
|
44
|
+
set?.delete(listener);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/** Subscribe to every request. */
|
|
48
|
+
onAnyRequest(listener) {
|
|
49
|
+
this.anyListeners.add(listener);
|
|
50
|
+
return makeSubscription(() => {
|
|
51
|
+
this.anyListeners.delete(listener);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Post a response into the embedded application. Returns `false` when nothing was
|
|
56
|
+
* sent: the client is destroyed, the iframe has no window, or no target origin could
|
|
57
|
+
* be resolved (see {@link EmbeddedAppClientOptions.targetOrigin}).
|
|
58
|
+
*/
|
|
59
|
+
send(response) {
|
|
60
|
+
if (this.destroyed) return false;
|
|
61
|
+
const target = this.frame.contentWindow;
|
|
62
|
+
const origin = this.resolveOrigin();
|
|
63
|
+
if (!target || !origin) return false;
|
|
64
|
+
target.postMessage(response, origin);
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
/** Answer a {@link MobileHostRequestType.RequestAuthToken} request. */
|
|
68
|
+
sendAuthToken(token) {
|
|
69
|
+
return this.send({ type: "AUTH_TOKEN" /* AuthToken */, token });
|
|
70
|
+
}
|
|
71
|
+
/** Post one scanned person of the current scanner session. */
|
|
72
|
+
sendScannerResult(person) {
|
|
73
|
+
return this.send({
|
|
74
|
+
type: "ULC_FORM_SCANNER_RESULT" /* ScannerResult */,
|
|
75
|
+
badgeId: person.badgeId,
|
|
76
|
+
result: person.fields
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
/** Fail the current scanner session with a display-ready message. */
|
|
80
|
+
sendScannerError(message) {
|
|
81
|
+
return this.send({ type: "ULC_FORM_SCANNER_RESULT" /* ScannerResult */, message });
|
|
82
|
+
}
|
|
83
|
+
/** End the current scanner session; send after the last result, or on cancel. */
|
|
84
|
+
sendScannerClosed() {
|
|
85
|
+
return this.send({ type: "ULC_FORM_SCANNER_CLOSED" /* ScannerClosed */ });
|
|
86
|
+
}
|
|
87
|
+
/** Remove the `message` listener and drop every subscription. Safe to call more than once. */
|
|
88
|
+
destroy() {
|
|
89
|
+
if (this.destroyed) return;
|
|
90
|
+
this.destroyed = true;
|
|
91
|
+
this.hostWindow.removeEventListener("message", this.boundHandler);
|
|
92
|
+
this.listeners.clear();
|
|
93
|
+
this.anyListeners.clear();
|
|
94
|
+
}
|
|
95
|
+
resolveOrigin() {
|
|
96
|
+
if (this.explicitOrigin) return this.explicitOrigin;
|
|
97
|
+
const src = "src" in this.frame ? this.frame.src : void 0;
|
|
98
|
+
return toOrigin(src);
|
|
99
|
+
}
|
|
100
|
+
handleMessage(event) {
|
|
101
|
+
if (this.matchSource && event.source !== this.frame.contentWindow) return;
|
|
102
|
+
const origin = this.resolveOrigin();
|
|
103
|
+
if (origin && origin !== "*" && event.origin !== origin) return;
|
|
104
|
+
const request = parseMobileHostRequest(event.data);
|
|
105
|
+
if (!request) return;
|
|
106
|
+
for (const listener of Array.from(this.anyListeners)) {
|
|
107
|
+
listener(request);
|
|
108
|
+
}
|
|
109
|
+
const set = this.listeners.get(request.type);
|
|
110
|
+
if (!set) return;
|
|
111
|
+
for (const listener of Array.from(set)) {
|
|
112
|
+
listener(request);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export { EmbeddedAppClient };
|
|
118
|
+
//# sourceMappingURL=embedded-app.js.map
|
|
119
|
+
//# sourceMappingURL=embedded-app.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/embedded-app.ts"],"names":[],"mappings":"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","file":"embedded-app.js","sourcesContent":["/**\n * `@captello/ulc-webview-sdk/embedded-app` — for the **Captello mobile app** (or any\n * host playing its role) that embeds an application in an iframe and serves it native\n * services over `postMessage`.\n *\n * {@link EmbeddedAppClient} wraps the iframe: it delivers the embedded application's\n * requests to typed handlers and posts the app's responses back. The embedded\n * application's own side is `MobileHostClient` (`@captello/ulc-webview-sdk/mobile-host`).\n *\n * Not to be confused with `CaptelloWebview`, which wraps an iframe of the *capture\n * webview* — a different protocol (JSON strings, snake_case types).\n *\n * @example\n * import { EmbeddedAppClient, MobileHostRequestType } from \"@captello/ulc-webview-sdk/embedded-app\";\n *\n * const embedded = new EmbeddedAppClient(iframe, { targetOrigin: new URL(iframe.src).origin });\n * embedded.onRequest(MobileHostRequestType.RequestAuthToken, async () => {\n * embedded.sendAuthToken(await mintMagicToken());\n * });\n * embedded.onRequest(MobileHostRequestType.OpenScanner, async () => {\n * for (const person of await scan()) embedded.sendScannerResult(person);\n * embedded.sendScannerClosed();\n * });\n */\n\nimport type { FrameLike, Unsubscribe } from \"./client\";\nimport {\n makeSubscription,\n MobileHostRequestType,\n MobileHostResponseType,\n parseMobileHostRequest,\n} from \"./mobile-app-protocol\";\nimport type { MobileHostRequest, MobileHostRequestMap, MobileHostResponse, ScannedPerson } from \"./mobile-app-protocol\";\n\nexport {\n MobileHostRequestType,\n MobileHostResponseType,\n parseMobileHostRequest,\n parseMobileHostResponse,\n} from \"./mobile-app-protocol\";\nexport type {\n MobileHostRequest,\n MobileHostRequestMap,\n MobileHostResponse,\n MobileHostResponseMap,\n AuthTokenMessage,\n ScannerResultMessage,\n ScannerClosedMessage,\n ScannedPerson,\n} from \"./mobile-app-protocol\";\nexport type { FrameLike } from \"./client\";\n\n/** Listener for a specific request type from the embedded application. */\nexport type MobileHostRequestListener<T extends MobileHostRequestType> = (request: MobileHostRequestMap[T]) => void;\n\n/** Listener for every request from the embedded application (used by {@link EmbeddedAppClient.onAnyRequest}). */\nexport type AnyMobileHostRequestListener = (request: MobileHostRequest) => void;\n\nexport interface EmbeddedAppClientOptions {\n /**\n * Origin to validate incoming requests against and to target outgoing responses.\n * A full URL is accepted and reduced to its origin.\n *\n * Defaults to the iframe's `src` origin, read fresh on every send so a `src` bound\n * after construction still works. When neither is available the client refuses to\n * send rather than fall back to `\"*\"` — a response can carry an auth token, and\n * `\"*\"` would hand it to whatever the frame navigated to.\n */\n targetOrigin?: string;\n /** Window to listen on. Defaults to the global `window`. */\n hostWindow?: Window;\n /**\n * Only accept requests whose `event.source` is the bound iframe's window. Defaults\n * to `true`. Set `false` if the application relays through another window.\n */\n matchSource?: boolean;\n}\n\nfunction toOrigin(value: string | undefined): string | undefined {\n if (!value) return undefined;\n if (value === \"*\") return value;\n try {\n return new URL(value).origin;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Mobile-app-side client for an application embedded in an iframe.\n *\n * Attaches a single `message` listener on construction; call {@link destroy} when the\n * iframe goes away.\n */\nexport class EmbeddedAppClient {\n private readonly frame: FrameLike;\n private readonly hostWindow: Window;\n private readonly explicitOrigin: string | undefined;\n private readonly matchSource: boolean;\n private readonly listeners = new Map<\n MobileHostRequestType,\n Set<MobileHostRequestListener<MobileHostRequestType>>\n >();\n private readonly anyListeners = new Set<AnyMobileHostRequestListener>();\n private readonly boundHandler = (event: MessageEvent) => this.handleMessage(event);\n private destroyed = false;\n\n constructor(frame: FrameLike, options: EmbeddedAppClientOptions = {}) {\n if (!frame) {\n throw new Error(\"EmbeddedAppClient: a mounted iframe element (or { contentWindow }) is required.\");\n }\n const hostWindow = options.hostWindow ?? (typeof window !== \"undefined\" ? window : undefined);\n if (!hostWindow) {\n throw new Error(\n \"EmbeddedAppClient: no host window available. Pass `hostWindow` when constructing outside a browser.\",\n );\n }\n this.frame = frame;\n this.hostWindow = hostWindow;\n this.explicitOrigin = toOrigin(options.targetOrigin);\n this.matchSource = options.matchSource ?? true;\n this.hostWindow.addEventListener(\"message\", this.boundHandler);\n }\n\n /** Subscribe to one request type. Returns a handle with `unsubscribe()` (also callable). */\n onRequest<T extends MobileHostRequestType>(type: T, listener: MobileHostRequestListener<T>): Unsubscribe {\n let set = this.listeners.get(type);\n if (!set) {\n set = new Set();\n this.listeners.set(type, set);\n }\n set.add(listener as MobileHostRequestListener<MobileHostRequestType>);\n return makeSubscription(() => {\n set?.delete(listener as MobileHostRequestListener<MobileHostRequestType>);\n });\n }\n\n /** Subscribe to every request. */\n onAnyRequest(listener: AnyMobileHostRequestListener): Unsubscribe {\n this.anyListeners.add(listener);\n return makeSubscription(() => {\n this.anyListeners.delete(listener);\n });\n }\n\n /**\n * Post a response into the embedded application. Returns `false` when nothing was\n * sent: the client is destroyed, the iframe has no window, or no target origin could\n * be resolved (see {@link EmbeddedAppClientOptions.targetOrigin}).\n */\n send(response: MobileHostResponse): boolean {\n if (this.destroyed) return false;\n const target = this.frame.contentWindow;\n const origin = this.resolveOrigin();\n if (!target || !origin) return false;\n target.postMessage(response, origin);\n return true;\n }\n\n /** Answer a {@link MobileHostRequestType.RequestAuthToken} request. */\n sendAuthToken(token: string): boolean {\n return this.send({ type: MobileHostResponseType.AuthToken, token });\n }\n\n /** Post one scanned person of the current scanner session. */\n sendScannerResult(person: ScannedPerson): boolean {\n return this.send({\n type: MobileHostResponseType.ScannerResult,\n badgeId: person.badgeId,\n result: person.fields,\n });\n }\n\n /** Fail the current scanner session with a display-ready message. */\n sendScannerError(message: string): boolean {\n return this.send({ type: MobileHostResponseType.ScannerResult, message });\n }\n\n /** End the current scanner session; send after the last result, or on cancel. */\n sendScannerClosed(): boolean {\n return this.send({ type: MobileHostResponseType.ScannerClosed });\n }\n\n /** Remove the `message` listener and drop every subscription. Safe to call more than once. */\n destroy(): void {\n if (this.destroyed) return;\n this.destroyed = true;\n this.hostWindow.removeEventListener(\"message\", this.boundHandler);\n this.listeners.clear();\n this.anyListeners.clear();\n }\n\n private resolveOrigin(): string | undefined {\n if (this.explicitOrigin) return this.explicitOrigin;\n const src = \"src\" in this.frame ? (this.frame as { src?: string }).src : undefined;\n return toOrigin(src);\n }\n\n private handleMessage(event: MessageEvent): void {\n if (this.matchSource && event.source !== this.frame.contentWindow) return;\n const origin = this.resolveOrigin();\n if (origin && origin !== \"*\" && event.origin !== origin) return;\n const request = parseMobileHostRequest(event.data);\n if (!request) return;\n for (const listener of Array.from(this.anyListeners)) {\n listener(request);\n }\n const set = this.listeners.get(request.type);\n if (!set) return;\n for (const listener of Array.from(set)) {\n listener(request);\n }\n }\n}\n"]}
|