Compare commits

..

25 Commits

Author SHA1 Message Date
iqudoo
b438364656 fix 2026-05-26 14:00:41 +08:00
iqudoo
229c8cb9e4 fix 2026-05-26 05:14:22 +08:00
iqudoo
cdec1838ac 优化++ 2026-05-26 02:37:01 +08:00
iqudoo
c08cdaee68 优化声音和安卓端适配 2026-05-25 20:48:54 +08:00
iqudoo
f52b9f3518 扫一扫音频提示音 2026-05-25 20:37:49 +08:00
iqudoo
6663fd6ab4 优化iframe内使用的优化 2026-05-25 19:12:35 +08:00
iqudoo
c56c17589a fix 2026-05-12 19:59:42 +08:00
iqudoo
bc025f7f74 修复了扫一扫的bug 2026-05-03 23:47:27 +08:00
iqudoo
4afa5ec8cb fix 2026-05-02 23:04:49 +08:00
iqudoo
69b0c4e27c fix 2026-05-02 17:19:14 +08:00
iqudoo
6e91cc0ef4 fix 2026-05-02 13:50:46 +08:00
iqudoo
1c02c72b3b fix 2026-05-02 13:47:46 +08:00
iqudoo
548814fe38 控制台打印当前支持的平台 2026-05-02 13:46:34 +08:00
iqudoo
ede67b61b4 嵌入模式识别 2026-05-02 13:40:28 +08:00
iqudoo
1966dbbd51 支持IFRAME嵌入模式,嵌入模式下都需要引入SDK 2026-05-02 13:14:17 +08:00
iqudoo
45686d28fc 统一个环境返回值 2026-05-02 11:52:06 +08:00
iqudoo
09b80d6b78 优化:兼容微信扫码带条码类型的结果 2026-05-02 11:44:59 +08:00
iqudoo
96006164b5 修复 2026-05-01 23:37:18 +08:00
iqudoo
36f1cbbf7e fix 2026-05-01 23:12:10 +08:00
iqudoo
ed1ee040cc fix 2026-05-01 23:11:43 +08:00
iqudoo
4e41a85e04 优化 2026-05-01 23:10:19 +08:00
iqudoo
c0fba11103 示例 2026-04-30 19:45:29 +08:00
iqudoo
dab2aa2d53 示例 2026-04-30 19:44:35 +08:00
iqudoo
23c91267a2 添加对微信小程序环境的支持 2026-04-30 19:43:13 +08:00
iqudoo
3856b6e8c9 fix 2026-04-30 19:35:24 +08:00
21 changed files with 3427 additions and 279 deletions

View File

@@ -1,6 +1,6 @@
# scan-code-jssdk # scan-code-jssdk
统一扫码 JSSDK支持桥接扫码、微信 JSSDK 扫码、Web 摄像头扫码、选择图片识别和扫码枪输入。 统一扫码 JSSDK支持桥接扫码、微信小程序、微信 JSSDK 扫码、Web 摄像头扫码、选择图片识别和扫码枪输入。
## 功能 ## 功能
@@ -61,9 +61,6 @@ IScan.config({
// 自动选择可用扫码方式:桥接 -> 微信 -> Web 摄像头 -> 图片识别 // 自动选择可用扫码方式:桥接 -> 微信 -> Web 摄像头 -> 图片识别
IScan.startScan(); IScan.startScan();
// 仅打开 Web 视频扫码
IScan.scanVideo();
// 仅选择图片识别 // 仅选择图片识别
IScan.scanImage(); IScan.scanImage();
@@ -83,11 +80,17 @@ interface ScanConfigOptions {
webScanEnabled?: boolean, webScanEnabled?: boolean,
webScanCanvasEnabled?: boolean; webScanCanvasEnabled?: boolean;
webScanCanvasStyle?: string; webScanCanvasStyle?: string;
webScanCloseButtonStyle?: string;
webScanCanvasClass?: string;
webScanCloseButtonClass?: string;
webScanType?: ("qrCode" | "barCode")[]; webScanType?: ("qrCode" | "barCode")[];
webScanVideoMirror?: boolean; webScanVideoMirror?: boolean;
webScanVideoMirrorVertical?: boolean; webScanVideoMirrorVertical?: boolean;
webScanBeepAudio?: string; webScanImageFallbackOnVideoError?: boolean;
webScanBeepEnabled?: boolean; webScanVideoAccessTimeout?: number;
webScanVideoReadyTimeout?: number;
scanBeepAudio?: string;
scanBeepEnabled?: boolean;
initWechatJssdk?: { initWechatJssdk?: {
apiUrl?: string; apiUrl?: string;
sdkConfig?: { sdkConfig?: {
@@ -110,12 +113,23 @@ interface ScanConfigOptions {
| `bridgeName` | 挂载在 `window` 上的桥接对象名称 | `__bridge_client__` | | `bridgeName` | 挂载在 `window` 上的桥接对象名称 | `__bridge_client__` |
| `webScanEnabled` | 是否支持 WebScan 扫码 | `true` | | `webScanEnabled` | 是否支持 WebScan 扫码 | `true` |
| `webScanCanvasEnabled` | 是否显示 WebScan 扫码 canvas关闭后仍会用隐藏 canvas 识别 | `true` | | `webScanCanvasEnabled` | 是否显示 WebScan 扫码 canvas关闭后仍会用隐藏 canvas 识别 | `true` |
| `webScanCanvasStyle` | WebScan 扫码 canvas 样式 | `position: fixed; width: 300px; height: 300px; top: 0; left: 0; z-index: 9999;` | | `webScanCanvasStyle` | WebScan 扫码 canvas 内联样式 | `position: fixed; width: 300px; height: 300px; top: 0; left: 0; z-index: 9999;` |
| `webScanCloseButtonStyle` | WebScan 关闭按钮内联样式(在默认位置上追加);按钮内容为 SVG`path` 使用 `currentColor`,可通过按钮的 `color` 改图标颜色 | 圆形半透明底、白图标,定位在 canvas 右上角 |
| `webScanCanvasClass` | WebScan canvas 根元素 `class`,便于用外部样式表配合 `webScanCanvasStyle` 定制 | 无 |
| `webScanCloseButtonClass` | WebScan 关闭按钮 `class`,便于用外部样式表配合 `webScanCloseButtonStyle` 定制 | 无 |
| `webScanType` | WebScan 扫码类型 | `["qrCode", "barCode"]` | | `webScanType` | WebScan 扫码类型 | `["qrCode", "barCode"]` |
| `webScanVideoMirror` | WebScan 视频是否水平镜像;不配置时自动判断:前置/PC 镜像,后置不镜像 | 自动 | | `webScanVideoMirror` | WebScan 视频是否水平镜像;不配置时自动判断:前置/PC 镜像,后置不镜像 | 自动 |
| `webScanVideoMirrorVertical` | WebScan 视频是否垂直镜像 | `false` | | `webScanVideoMirrorVertical` | WebScan 视频是否垂直镜像 | `false` |
| `webScanBeepAudio` | WebScan 扫码成功提示音地址 | 内置提示音 | | `webScanImageFallbackOnVideoError` | 摄像头不可用或打开失败时,是否自动弹出拍照/选图(适用于部分安卓内置浏览器) | `true` |
| `webScanBeepEnabled` | WebScan 扫码成功是否播放提示音 | `true` | | `webScanVideoAccessTimeout` | 打开摄像头超时(毫秒),超时后走图片回退 | `10000` |
| `webScanVideoReadyTimeout` | 摄像头已开但无画面超时(毫秒),超时后走图片回退 | `8000` |
| `webScanCameraPermissionDialogEnabled` | `startScan` 走 Web 摄像头前是否先展示权限说明弹窗 | `true` |
| `webScanCameraPermissionTitle` | 权限说明弹窗标题 | `需要使用摄像头` |
| `webScanCameraPermissionMessage` | 权限说明弹窗正文 | 见类型定义默认值 |
| `webScanCameraPermissionConfirmText` | 确认按钮文案 | `继续` |
| `webScanCameraPermissionCancelText` | 取消按钮文案 | `取消` |
| `scanBeepAudio` | 扫码成功提示音地址(任意模式匹配成功时播放) | 内置提示音 |
| `scanBeepEnabled` | 扫码成功是否播放提示音 | `true` |
| `initWechatJssdk` | 微信 JSSDK 初始化配置,仅微信环境生效 | 无 | | `initWechatJssdk` | 微信 JSSDK 初始化配置,仅微信环境生效 | 无 |
`initWechatJssdk` 子配置: `initWechatJssdk` 子配置:
@@ -212,7 +226,9 @@ IScan.config({
```js ```js
IScan.config({ IScan.config({
webScanCanvasEnabled: true webScanCanvasEnabled: true,
webScanCanvasClass: "my-webscan-canvas",
webScanCloseButtonClass: "my-webscan-close"
}); });
``` ```
@@ -278,14 +294,6 @@ console.log(IScan.getStatus());
IScan.startScan(); IScan.startScan();
``` ```
### `scanVideo(): void`
直接开启 Web 摄像头扫码。扫码结果通过 `onScanListener` 回调。
```js
IScan.scanVideo();
```
### `scanImage(): void` ### `scanImage(): void`
直接选择图片进行识别。识别结果通过 `onScanListener` 回调。 直接选择图片进行识别。识别结果通过 `onScanListener` 回调。

View File

@@ -4,7 +4,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no" /> <meta name="viewport" content="width=device-width,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no" />
<title>IScan</title> <title>IScan 使用示例</title>
<script src="https://cdn.jsdelivr.net/npm/vconsole@3.12.0/dist/vconsole.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/vconsole@3.12.0/dist/vconsole.min.js"></script>
<script> <script>
var vconsole = new VConsole(); var vconsole = new VConsole();
@@ -180,8 +180,8 @@
<body> <body>
<main class="page"> <main class="page">
<section class="hero"> <section class="hero">
<h1>IScan 通用扫码 SDK</h1> <h1>IScan 通用扫码SDK</h1>
<p>统一接入桥接扫码、微信 JSSDK 扫码、Web 摄像头扫码、图片识别和扫码枪输入。</p> <p>统一接入桥接扫码、微信小程序、微信 JSSDK 扫码、Web 摄像头扫码、图片识别和扫码枪输入。</p>
</section> </section>
<section class="grid"> <section class="grid">
@@ -207,7 +207,6 @@
<p>点击开始后会按桥接、微信、Web 摄像头、图片识别的顺序选择可用扫码方式。</p> <p>点击开始后会按桥接、微信、Web 摄像头、图片识别的顺序选择可用扫码方式。</p>
<div class="actions"> <div class="actions">
<button onclick="startScan()" class="btn">开始扫码</button> <button onclick="startScan()" class="btn">开始扫码</button>
<button onclick="scanVideo()" class="btn secondary">开启视频扫码</button>
<button onclick="scanImage()" class="btn secondary">选择图片识别</button> <button onclick="scanImage()" class="btn secondary">选择图片识别</button>
<button onclick="stopScan()" class="btn secondary">停止扫码</button> <button onclick="stopScan()" class="btn secondary">停止扫码</button>
</div> </div>
@@ -229,7 +228,8 @@
<pre>IScan.config({ <pre>IScan.config({
webScanEnabled: true, webScanEnabled: true,
webScanCanvasEnabled: true, webScanCanvasEnabled: true,
webScanBeepEnabled: true, webScanCloseButtonStyle: "background: rgba(27, 99, 244, 0.88);",
scanBeepEnabled: true,
initWechatJssdk: { initWechatJssdk: {
apiUrl: "https://your-domain.com/wechat/jssdk-config" apiUrl: "https://your-domain.com/wechat/jssdk-config"
} }
@@ -246,7 +246,6 @@
IScan.startScan(); IScan.startScan();
IScan.scanImage(); IScan.scanImage();
IScan.scanVideo();
IScan.stopScan();</pre> IScan.stopScan();</pre>
</section> </section>
</main> </main>
@@ -319,7 +318,8 @@ IScan.stopScan();</pre>
var url = "https://vet.iqudoo.com/api?action=api.biz.wechat.JSSDKConfig"; var url = "https://vet.iqudoo.com/api?action=api.biz.wechat.JSSDKConfig";
initSDK({ initSDK({
webScanCanvasEnabled: true, webScanCanvasEnabled: true,
webScanBeepEnabled: true, webScanCloseButtonStyle: "background: rgba(27, 99, 244, 0.88);",
scanBeepEnabled: true,
initWechatJssdk: { apiUrl: url } initWechatJssdk: { apiUrl: url }
}, function () { }, function () {
setStatus(IScan.getStatus()); setStatus(IScan.getStatus());
@@ -347,10 +347,6 @@ IScan.stopScan();</pre>
IScan.scanImage(); IScan.scanImage();
} }
function scanVideo() {
IScan.scanVideo();
}
ready(); ready();
</script> </script>
</body> </body>

186
dist/index.d.ts vendored
View File

@@ -6,6 +6,18 @@ interface ScanConfigOptions {
* 扫码重启延迟单位毫秒默认500ms * 扫码重启延迟单位毫秒默认500ms
*/ */
scanRestartDelay?: number, scanRestartDelay?: number,
/**
* iframe 场景下是否将 API 调用转发到父页面同名 SDKpostMessage
* - `'auto'`(默认):处于子 frame 时 `startScan` 等由父页执行;`onScanListener` 仅注册在 iframe 内,识别结果由父页 `postMessage` 回传
* - `true` / `'on'` / `'parent'`:存在父 window 时强制转发
* - `false` / `'off'` / `'local'`:始终在本页执行(子页自己要跑扫码时用)
*/
embedProxyMode?: 'auto' | boolean | 'on' | 'off' | 'local' | 'parent',
/**
* 请求微信 JS-SDK 签名时使用的页面 URL不含 hash
* 跨域 iframe 无法读取父页地址时需手动设为当前微信内打开的页面链接。
*/
wxJssdkSignatureUrl?: string,
/** /**
* 桥接是否启用,默认启用 * 桥接是否启用,默认启用
*/ */
@@ -33,6 +45,58 @@ interface ScanConfigOptions {
* 2. 结束扫码的方法名称为stopScan * 2. 结束扫码的方法名称为stopScan
*/ */
bridgeName?: string, bridgeName?: string,
/**
* 桥接扫码超时(毫秒),超时后回退 Web/图片识别,默认 5000
*/
bridgeScanTimeout?: number,
/**
* 是否允许 H5 摄像头扫码true 强制开启(仍需有媒体 APIfalse 强制关闭
*/
webScanCameraEnabled?: boolean,
/**
* @deprecated 请用 webScanCameraEnabled保留兼容
*/
webScanCameraInWechat?: boolean,
/**
* @deprecated 摄像头权限已后置到 startScan请用 webScanVideoAccessTimeout
*/
webScanCameraProbeTimeout?: number,
/**
* startScan 走 Web 摄像头前是否展示权限说明弹窗,默认 true
*/
webScanCameraPermissionDialogEnabled?: boolean,
/**
* 摄像头权限说明弹窗标题
*/
webScanCameraPermissionTitle?: string,
/**
* 摄像头权限说明弹窗正文
*/
webScanCameraPermissionMessage?: string,
/**
* 摄像头权限说明弹窗确认按钮文案
*/
webScanCameraPermissionConfirmText?: string,
/**
* 摄像头权限说明弹窗取消按钮文案
*/
webScanCameraPermissionCancelText?: string,
/**
* 选图后延迟读取 file 对象(毫秒),微信/安卓 WebView 默认 100
*/
webScanFileReadDelay?: number,
/**
* 强制使用 WASM ponyfill 识别(微信/部分 WebView 建议开启,默认微信内自动开启)
*/
webScanPreferPonyfill?: boolean,
/**
* WASM 文件完整 URL未配置时相对 SDK 脚本地址解析 lib/reader.wasm
*/
webScanWasmUrl?: string,
/**
* WASM 基准路径SDK 脚本 URL 或目录);用于 async/defer 加载时修正 reader.wasm 路径
*/
webScanWasmBaseUrl?: string,
/** /**
* webScan是否启用默认启用 * webScan是否启用默认启用
*/ */
@@ -42,9 +106,29 @@ interface ScanConfigOptions {
*/ */
webScanCanvasEnabled?: boolean, webScanCanvasEnabled?: boolean,
/** /**
* 网页扫码canvas样式默认position: fixed; width: 300px; height: 300px; top: 0; left: 0; z-index: 9999; * 网页扫码canvas样式默认PC 为左上角固定 300×300移动端为 min(视口宽, 视口高) 的正方形,固定在左上角;z-index: 9999
*/ */
webScanCanvasStyle?: string, webScanCanvasStyle?: string,
/**
* 网页扫码canvas关闭按钮样式默认显示在canvas右上角
*/
webScanCloseButtonStyle?: string,
/**
* 网页扫码 canvas 根元素的 class便于配合外部样式表定制布局与外观
*/
webScanCanvasClass?: string,
/**
* 网页扫码关闭按钮的 class
*/
webScanCloseButtonClass?: string,
/**
* Canvas 开启且支持图片识别时关闭按钮下方的「选图」按钮样式fixed 定位基准由 SDK 计算,可与关闭按钮一致覆盖)
*/
webScanPickImageButtonStyle?: string,
/**
* 网页扫码选图按钮的 class
*/
webScanPickImageButtonClass?: string,
/** /**
* 网页扫码类型,默认支持二维码和条码 * 网页扫码类型,默认支持二维码和条码
*/ */
@@ -58,13 +142,60 @@ interface ScanConfigOptions {
*/ */
webScanVideoMirrorVertical?: boolean, webScanVideoMirrorVertical?: boolean,
/** /**
* 网页扫码成功提示音地址,默认使用内置提示音 * 摄像头不可用(如部分安卓内置浏览器禁用 getUserMedia时是否自动回退为拍照/选图识别,默认启用
*/ */
webScanBeepAudio?: string, webScanImageFallbackOnVideoError?: boolean,
/** /**
* 网页扫码成功提示音是否启用,默认启用 * 打开摄像头超时时间(毫秒),超时后触发图片回退,默认 10000
*/ */
webScanBeepEnabled?: boolean, webScanVideoAccessTimeout?: number,
/**
* 摄像头已打开但长时间无画面时触发图片回退(毫秒),默认 8000
*/
webScanVideoReadyTimeout?: number,
/**
* 图片/回退识别时 detect 超时(毫秒),默认 15000
*/
webScanDetectTimeout?: number,
/**
* 图片识别是否优先使用 ZXing ponyfill原生 detect 在部分 WebView 可能卡住),默认 true
*/
webScanImagePreferPonyfill?: boolean,
/**
* 移动端图片回退是否使用 capture 拍照false 为相册选图,兼容性更好),默认 false
*/
webScanImagePreferCapture?: boolean,
/**
* 图片识别是否优先 canvas 解码(安卓 WebView 建议开启),默认在安卓/微信内自动开启
*/
webScanImageDetectPreferCanvas?: boolean,
/**
* ZXing WASM 加载超时(毫秒),默认 20000
*/
webScanPrepareTimeout?: number,
/**
* 选图等待超时(毫秒),默认 120000
*/
webScanChooseImageTimeout?: number,
/**
* 选图方式button=显示「选择图片」按钮(安卓/微信默认需用户点击auto=自动弹出系统选图
*/
webScanImagePickerMode?: 'auto' | 'button',
webScanImagePickerTitle?: string,
webScanImagePickerButtonText?: string,
webScanImagePickerCancelText?: string,
/**
* 单次扫码会话超时(毫秒),超时后状态恢复 ready默认 90000
*/
scanSessionTimeout?: number,
/**
* 扫码成功提示音地址,默认使用内置提示音;任意识别模式匹配成功时播放
*/
scanBeepAudio?: string,
/**
* 扫码成功是否播放提示音,默认启用;任意识别模式匹配成功时生效
*/
scanBeepEnabled?: boolean,
/** /**
* 微信JSSDK配置微信环境才会生效配置后会自动初始化微信JSSDK * 微信JSSDK配置微信环境才会生效配置后会自动初始化微信JSSDK
*/ */
@@ -117,6 +248,16 @@ interface ScanResult {
key: string key: string
} }
/**
* 扫码错误
*/
interface ScanErrorInfo {
error: string,
key: string,
source?: string,
cancel?: number
}
/** /**
* 监听key * 监听key
*/ */
@@ -153,6 +294,22 @@ type ScanStatus = "scanning" | "ready";
*/ */
type ScanResultCallback = (result: ScanResult) => any; type ScanResultCallback = (result: ScanResult) => any;
/**
* 监听扫码错误回调
*/
type ScanErrorCallback = (error: ScanErrorInfo) => any;
/**
* 扫码错误监听信息
*/
interface ScanErrorListenerInfo {
key?: string;
match?: string;
level?: number;
listener: ScanErrorCallback;
cancel: () => void;
}
/** /**
* 监听状态回调 * 监听状态回调
*/ */
@@ -183,6 +340,19 @@ interface IScan {
* @param callback 监听回调或监听key * @param callback 监听回调或监听key
*/ */
offScanListener(callback: ScanResultCallback | string): void; offScanListener(callback: ScanResultCallback | string): void;
/**
* 添加监听扫码错误(如图片识别失败)
* @param callback 错误回调
* @param key 监听 key
* @param match 可选正则,匹配 error 文本后回调
* @param level 优先级
*/
onScanErrorListener(callback: ScanErrorCallback, key: string, match?: string, level?: number): ScanErrorListenerInfo;
/**
* 取消监听扫码错误
* @param callback 监听回调,或监听 key
*/
offScanErrorListener(callback: ScanErrorCallback | string): void;
/** /**
* 获取扫码状态 * 获取扫码状态
* @returns ScanStatus * @returns ScanStatus
@@ -196,14 +366,12 @@ interface IScan {
* 开启扫码 * 开启扫码
*/ */
startScan(): void; startScan(): void;
/**
* 开启视频扫码
*/
scanVideo(): void;
/** /**
* 选择图片进行识别 * 选择图片进行识别
*/ */
scanImage(): void; scanImage(): void;
/** 由业务/原生传入已选图片 File 识别WebView input.files 异常时使用) */
scanImageFromFile(file: File | Blob): void;
/** /**
* 清除全部监听 * 清除全部监听
*/ */

15
dist/index.html vendored
View File

@@ -1,4 +1,4 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,minimum-scale=1,maximum-scale=1,user-scalable=no"/><title>IScan</title><script src="https://cdn.jsdelivr.net/npm/vconsole@3.12.0/dist/vconsole.min.js"></script><script>var vconsole = new VConsole();</script><style>* { <!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,minimum-scale=1,maximum-scale=1,user-scalable=no"/><title>IScan 使用示例</title><script src="https://cdn.jsdelivr.net/npm/vconsole@3.12.0/dist/vconsole.min.js"></script><script>var vconsole = new VConsole();</script><style>* {
box-sizing: border-box; box-sizing: border-box;
} }
@@ -161,10 +161,11 @@
section { section {
margin-bottom: 16px; margin-bottom: 16px;
}</style></head><body><main class="page"><section class="hero"><h1>IScan 通用扫码 SDK</h1><p>统一接入桥接扫码、微信 JSSDK 扫码、Web 摄像头扫码、图片识别和扫码枪输入。</p></section><section class="grid"><div class="card"><h2>功能说明</h2><ul class="feature-list"><li>支持桥接扫码、微信 JSSDK 扫码、Web 摄像头扫码、图片识别和扫码枪输入。</li><li>浏览器环境使用WASM库识别二维码和条形码。</li><li>扫码结果由监听规则统一回调,支持正则匹配和优先级排序。</li></ul></div><div class="card"><h2>当前状态</h2><p>SDK 状态:<span id="status" class="status">loading</span></p><p>运行环境:</p><pre id="output" class="panel"></pre></div></section><section class="card"><h2>操作</h2><p>点击开始后会按桥接、微信、Web 摄像头、图片识别的顺序选择可用扫码方式。</p><div class="actions"><button onclick="startScan()" class="btn">开始扫码</button> <button onclick="scanVideo()" class="btn secondary">开启视频扫码</button> <button onclick="scanImage()" class="btn secondary">选择图片识别</button> <button onclick="stopScan()" class="btn secondary">停止扫码</button></div></section><section class="card"><h2>扫码结果</h2><pre id="result" class="panel result"></pre></section><section class="card"><h2>错误信息</h2><pre id="error" class="panel error"></pre><p style="color: #919191">错误信息可能来源于扫码结果、扫码过程、扫码初始化等。</p></section><section class="card code"><h2>接入方式</h2><pre>IScan.config({ }</style></head><body><main class="page"><section class="hero"><h1>IScan 通用扫码SDK</h1><p>统一接入桥接扫码、微信小程序、微信 JSSDK 扫码、Web 摄像头扫码、图片识别和扫码枪输入。</p></section><section class="grid"><div class="card"><h2>功能说明</h2><ul class="feature-list"><li>支持桥接扫码、微信 JSSDK 扫码、Web 摄像头扫码、图片识别和扫码枪输入。</li><li>浏览器环境使用WASM库识别二维码和条形码。</li><li>扫码结果由监听规则统一回调,支持正则匹配和优先级排序。</li></ul></div><div class="card"><h2>当前状态</h2><p>SDK 状态:<span id="status" class="status">loading</span></p><p>运行环境:</p><pre id="output" class="panel"></pre></div></section><section class="card"><h2>操作</h2><p>点击开始后会按桥接、微信、Web 摄像头、图片识别的顺序选择可用扫码方式。</p><div class="actions"><button onclick="startScan()" class="btn">开始扫码</button> <button onclick="scanImage()" class="btn secondary">选择图片识别</button> <button onclick="stopScan()" class="btn secondary">停止扫码</button></div></section><section class="card"><h2>扫码结果</h2><pre id="result" class="panel result"></pre></section><section class="card"><h2>错误信息</h2><pre id="error" class="panel error"></pre><p style="color: #919191">错误信息可能来源于扫码结果、扫码过程、扫码初始化等。</p></section><section class="card code"><h2>接入方式</h2><pre>IScan.config({
webScanEnabled: true, webScanEnabled: true,
webScanCanvasEnabled: true, webScanCanvasEnabled: true,
webScanBeepEnabled: true, webScanCloseButtonStyle: "background: rgba(27, 99, 244, 0.88);",
scanBeepEnabled: true,
initWechatJssdk: { initWechatJssdk: {
apiUrl: "https://your-domain.com/wechat/jssdk-config" apiUrl: "https://your-domain.com/wechat/jssdk-config"
} }
@@ -181,7 +182,6 @@
IScan.startScan(); IScan.startScan();
IScan.scanImage(); IScan.scanImage();
IScan.scanVideo();
IScan.stopScan();</pre></section></main><script>(function () { IScan.stopScan();</pre></section></main><script>(function () {
output(window.navigator.userAgent); output(window.navigator.userAgent);
window.onerror = function (message, source, lineno, colno, err) { window.onerror = function (message, source, lineno, colno, err) {
@@ -249,7 +249,8 @@ IScan.stopScan();</pre></section></main><script>(function () {
var url = "https://vet.iqudoo.com/api?action=api.biz.wechat.JSSDKConfig"; var url = "https://vet.iqudoo.com/api?action=api.biz.wechat.JSSDKConfig";
initSDK({ initSDK({
webScanCanvasEnabled: true, webScanCanvasEnabled: true,
webScanBeepEnabled: true, webScanCloseButtonStyle: "background: rgba(27, 99, 244, 0.88);",
scanBeepEnabled: true,
initWechatJssdk: { apiUrl: url } initWechatJssdk: { apiUrl: url }
}, function () { }, function () {
setStatus(IScan.getStatus()); setStatus(IScan.getStatus());
@@ -277,8 +278,4 @@ IScan.stopScan();</pre></section></main><script>(function () {
IScan.scanImage(); IScan.scanImage();
} }
function scanVideo() {
IScan.scanVideo();
}
ready();</script><script src="index.js"></script></body></html> ready();</script><script src="index.js"></script></body></html>

2
dist/index.js vendored

File diff suppressed because one or more lines are too long

342
dist/index.md vendored Normal file
View File

@@ -0,0 +1,342 @@
# scan-code-jssdk
统一扫码 JSSDK支持桥接扫码、微信小程序、微信 JSSDK 扫码、Web 摄像头扫码、选择图片识别和扫码枪输入。
## 功能
- App 桥接环境:优先调用原生桥接 `startScan` / `stopScan`
- 微信浏览器:初始化微信 JSSDK 后调用 `wx.scanQRCode`
- Web 浏览器:使用 `BarcodeDetector` 识别二维码和条形码。
- 图片识别:选择本地图片后识别二维码/条形码。
- 扫码枪:监听键盘快速输入并统一走扫码监听回调。
- 监听规则:支持按 `match` 正则和 `level` 优先级分发扫码结果。
## 引入
构建后使用 `dist/index.js`
```html
<script src="./dist/index.js"></script>
<script>
IScan.config().then(function () {
console.log("IScan ready");
});
</script>
```
如果脚本是后加载的,可以监听 `IScanReady`
```js
function init() {
IScan.config().then(function () {
// ready
});
}
if (window.IScan) {
init();
} else {
window.addEventListener("IScanReady", init, { once: true });
}
```
## 快速开始
```js
IScan.config({
initWechatJssdk: {
apiUrl: "https://your-domain.com/wechat/jssdk-config"
}
}).then(function () {
// 监听识别状态
IScan.setStatusListener(function (status) {
console.log("status:", status);
});
// 监听识别结果
IScan.onScanListener(function (res) {
console.log("scan result:", res.result, res.key);
}, "scan", null, 100);
});
// 自动选择可用扫码方式:桥接 -> 微信 -> Web 摄像头 -> 图片识别
IScan.startScan();
// 仅选择图片识别
IScan.scanImage();
// 停止当前扫码
IScan.stopScan();
```
## 配置项
通过 `IScan.config(options)` 配置。
```ts
interface ScanConfigOptions {
scanRestartDelay?: number,
bridgeEnabled?: boolean;
bridgeName?: string;
webScanEnabled?: boolean,
webScanCanvasEnabled?: boolean;
webScanCanvasStyle?: string;
webScanCloseButtonStyle?: string;
webScanCanvasClass?: string;
webScanCloseButtonClass?: string;
webScanType?: ("qrCode" | "barCode")[];
webScanVideoMirror?: boolean;
webScanVideoMirrorVertical?: boolean;
webScanImageFallbackOnVideoError?: boolean;
webScanVideoAccessTimeout?: number;
webScanVideoReadyTimeout?: number;
scanBeepAudio?: string;
scanBeepEnabled?: boolean;
initWechatJssdk?: {
apiUrl?: string;
sdkConfig?: {
debug?: boolean;
appId: string;
timestamp: number;
nonceStr: string;
signature: string;
};
sdkUrl?: string;
jsApiList?: string[];
};
}
```
| 配置 | 说明 | 默认值 |
| --- | --- | --- |
| `scanRestartDelay` | 扫码重启延迟,单位:毫秒 | `500ms` |
| `bridgeEnabled` | 是否启用桥接扫码 | `true` |
| `bridgeName` | 挂载在 `window` 上的桥接对象名称 | `__bridge_client__` |
| `webScanEnabled` | 是否支持 WebScan 扫码 | `true` |
| `webScanCanvasEnabled` | 是否显示 WebScan 扫码 canvas关闭后仍会用隐藏 canvas 识别 | `true` |
| `webScanCanvasStyle` | WebScan 扫码 canvas 内联样式 | `position: fixed; width: 300px; height: 300px; top: 0; left: 0; z-index: 9999;` |
| `webScanCloseButtonStyle` | WebScan 关闭按钮内联样式(在默认位置上追加);按钮内容为 SVG`path` 使用 `currentColor`,可通过按钮的 `color` 改图标颜色 | 圆形半透明底、白图标,定位在 canvas 右上角 |
| `webScanCanvasClass` | WebScan canvas 根元素 `class`,便于用外部样式表配合 `webScanCanvasStyle` 定制 | 无 |
| `webScanCloseButtonClass` | WebScan 关闭按钮 `class`,便于用外部样式表配合 `webScanCloseButtonStyle` 定制 | 无 |
| `webScanType` | WebScan 扫码类型 | `["qrCode", "barCode"]` |
| `webScanVideoMirror` | WebScan 视频是否水平镜像;不配置时自动判断:前置/PC 镜像,后置不镜像 | 自动 |
| `webScanVideoMirrorVertical` | WebScan 视频是否垂直镜像 | `false` |
| `webScanImageFallbackOnVideoError` | 摄像头不可用或打开失败时,是否自动弹出拍照/选图(适用于部分安卓内置浏览器) | `true` |
| `webScanVideoAccessTimeout` | 打开摄像头超时(毫秒),超时后走图片回退 | `10000` |
| `webScanVideoReadyTimeout` | 摄像头已开但无画面超时(毫秒),超时后走图片回退 | `8000` |
| `webScanCameraPermissionDialogEnabled` | `startScan` 走 Web 摄像头前是否先展示权限说明弹窗 | `true` |
| `webScanCameraPermissionTitle` | 权限说明弹窗标题 | `需要使用摄像头` |
| `webScanCameraPermissionMessage` | 权限说明弹窗正文 | 见类型定义默认值 |
| `webScanCameraPermissionConfirmText` | 确认按钮文案 | `继续` |
| `webScanCameraPermissionCancelText` | 取消按钮文案 | `取消` |
| `scanBeepAudio` | 扫码成功提示音地址(任意模式匹配成功时播放) | 内置提示音 |
| `scanBeepEnabled` | 扫码成功是否播放提示音 | `true` |
| `initWechatJssdk` | 微信 JSSDK 初始化配置,仅微信环境生效 | 无 |
`initWechatJssdk` 子配置:
| 配置 | 说明 | 默认值 |
| --- | --- | --- |
| `apiUrl` | 微信 JSSDK 签名配置接口地址;未传 `sdkConfig` 时会请求该接口,并自动携带当前页面 URL 参数 | 无 |
| `sdkConfig` | 直接传入微信 JSSDK 签名配置;配置后不再请求 `apiUrl` | 无 |
| `sdkConfig.debug` | 是否开启微信 JSSDK 调试模式 | `false` |
| `sdkConfig.appId` | 微信公众平台应用 ID | 必填 |
| `sdkConfig.timestamp` | 签名时间戳 | 必填 |
| `sdkConfig.nonceStr` | 签名随机字符串 | 必填 |
| `sdkConfig.signature` | 微信 JSSDK 签名 | 必填 |
| `sdkUrl` | 微信 JSSDK 脚本地址 | `https://res.wx.qq.com/open/js/jweixin-1.6.0.js` |
| `jsApiList` | 微信 JSSDK JS-API 列表SDK 会自动追加 `scanQRCode` | `["scanQRCode"]` |
## 桥接接入
桥接对象需要挂载到 `window[bridgeName]`,并实现 `call(method, data)`
SDK 会调用:
- `startScan`
- `stopScan`
异步回调方法名为 `${bridgeName}_handle_callback`
```js
window.__bridge_client__ = {
call: function (method, data) {
var requestId = data.request_id;
if (method === "startScan") {
// 调用原生扫码后回调
window.__bridge_client___handle_callback({
code: 0,
method: method,
request_id: requestId,
payload: {
result: "https://example.com"
}
});
}
if (method === "stopScan") {
window.__bridge_client___handle_callback({
code: 0,
method: method,
request_id: requestId,
payload: {}
});
}
}
};
```
## 微信 JSSDK 接入
配置接口方式:
```js
IScan.config({
initWechatJssdk: {
apiUrl: "https://your-domain.com/wechat/jssdk-config"
}
});
```
接口会收到当前页面 URL 参数:`url=location.href.split("#")[0]`
也可以直接传入微信签名配置:
```js
IScan.config({
initWechatJssdk: {
apiUrl: "",
sdkConfig: {
appId: "wx_app_id",
timestamp: 123456,
nonceStr: "nonce",
signature: "signature"
}
}
});
```
`jsApiList` 会默认追加 `scanQRCode`
## API
### `config(options?): Promise<any>`
配置并初始化 SDK。
```js
IScan.config({
webScanCanvasEnabled: true,
webScanCanvasClass: "my-webscan-canvas",
webScanCloseButtonClass: "my-webscan-close"
});
```
### `setStatusListener(callback): void`
监听扫码状态,状态为:
- `scanning`
- `closed`
```js
IScan.setStatusListener(function (status) {
console.log(status);
});
```
### `onScanListener(callback, key, match?, level?): ScanListenerInfo`
添加扫码结果监听。
```js
var listener = IScan.onScanListener(function (res) {
console.log(res.result, res.key);
}, "order", "^https://", 100);
```
参数说明:
- `callback`:扫码结果回调。
- `key`:监听 key同 key 会覆盖旧监听。
- `match`:可选正则字符串;扫码结果匹配后才回调。
- `level`:优先级,数值越大越先匹配。
### `offScanListener(callbackOrKey): void`
移除监听,可传 callback 或 key。
```js
IScan.offScanListener("order");
listener.cancel();
```
### `getStatus(): "scanning" | "ready"`
获取当前扫码状态。
```js
console.log(IScan.getStatus());
```
### `startScan(): void`
开启扫码。SDK 会按以下顺序选择可用能力:
1. 桥接扫码
2. 微信扫码
3. Web 摄像头扫码
4. 图片识别
扫码结果通过 `onScanListener` 回调。
```js
IScan.startScan();
```
### `scanImage(): void`
直接选择图片进行识别。识别结果通过 `onScanListener` 回调。
```js
IScan.scanImage();
```
### `stopScan(): void`
停止当前扫码。
```js
IScan.stopScan();
```
### `clear(): void`
清空全部扫码监听。
```js
IScan.clear();
```
## 类型
```ts
interface ScanResult {
result: string;
key: string;
}
type ScanStatus = "scanning" | "ready";
type ScanResultCallback = (result: ScanResult) => any;
type ScanStatusCallback = (status: ScanStatus) => any;
```
## 开发
```bash
npm install
npm run build
```
构建产物输出到 `dist` 目录。

View File

@@ -1,11 +1,19 @@
import './polyfill'; import './polyfill';
import { import {
onScanListener, offScanListener, setStatusListener, getStatus, supportList,
startScan, stopScan, scanVideo, scanImage, clear onScanListener, offScanListener,
onScanErrorListener, offScanErrorListener,
setStatusListener, getStatus,
startScan, stopScan, scanImage, scanImageFromFile, clear
} from './services/provider/scan'; } from './services/provider/scan';
import { setConfig, getVersion } from './services/config'; import { setConfig, getVersion } from './services/config';
import { initWxJssdk } from './services/wx'; import { initWxJssdk } from './services/wx';
import { printDebug } from './utils/logger'; import {
isSupportWebScan,
prepareWebScanBarcodeDetector,
isSupportImageScan
} from './services/web';
import { printDebug, printWarn } from './utils/logger';
let _readyPromise = null; let _readyPromise = null;
let _calledReady = false; let _calledReady = false;
@@ -22,17 +30,25 @@ function config(config) {
return _readyPromise; return _readyPromise;
} }
_readyPromise = Promise.resolve().then(() => { _readyPromise = Promise.resolve().then(() => {
return initWxJssdk().catch(err => {
printDebug('init wx jssdk failed:', err && err.message ? err.message : err);
});
}).then(() => {
printDebug('-------------------------------------'); printDebug('-------------------------------------');
printDebug('sdk_version:', getVersion()); printDebug('sdk_version:', getVersion());
printDebug('support_list:', supportList.map(item => item.name + ':' + item.support).join(', '));
printDebug('-------------------------------------'); printDebug('-------------------------------------');
initWxJssdk(); if (isSupportWebScan() || isSupportImageScan()) {
return Promise.resolve().then(() => { return prepareWebScanBarcodeDetector().catch(err => {
_calledReady = true; printWarn('prepare barcode detector failed:', err);
}).catch(err => { });
_readyPromise = null; }
throw err; }).then(() => {
}); _calledReady = true;
}) }).catch(err => {
_readyPromise = null;
throw err;
});
return _readyPromise; return _readyPromise;
} }
@@ -40,11 +56,13 @@ export default Object.assign({}, {
config, config,
onScanListener, onScanListener,
offScanListener, offScanListener,
onScanErrorListener,
offScanErrorListener,
setStatusListener, setStatusListener,
getStatus, getStatus,
startScan, startScan,
stopScan, stopScan,
scanVideo,
scanImage, scanImage,
scanImageFromFile,
clear, clear,
}); });

View File

@@ -1,4 +1,403 @@
import _core, { isReadyCalled } from "./_core"; import { isReadyCalled } from "./_core";
import { getConfig } from "./services/config";
import {
readWxLikeEnvFromWindow,
setParentWxEnvReport,
getParentWxEnvReport,
} from "./services/embedEnvProbe";
import {
hasDistinctParentWindow,
resolveUseParentProxy,
} from "./services/embedProxy";
import { createUUID } from "./utils/uuid";
import {
dispatchEmbedScanResult,
acknowledgeEmbedScanConsumed,
dispatchEmbedScanError,
setEmbedScanHostEnabled,
} from "./services/provider/scan";
import {
setEmbedScanResultForwarder,
setEmbedScanErrorForwarder,
} from "./services/embedScanBridge";
import { unlockScanBeep, installScanBeepGestureUnlock } from "./services/web";
const EMBED_SOURCE = "IScanEmbed";
const EMBED_V = 1;
/** 已向父页发起过 invoke 的嵌入子 frame用于父页识别结果回传 */
const embedChildSources = new Set();
const EMBED_LISTENER_METHODS = new Set([
"onScanListener",
"offScanListener",
"onScanErrorListener",
"offScanErrorListener",
"clear",
]);
/** 嵌入 iframe 转发到父页时,须在子页用用户手势解锁提示音 */
const EMBED_SCAN_GESTURE_METHODS = new Set([
"startScan",
"scanImage",
"scanImageFromFile",
]);
function isEmbedMessage(data) {
return data && data.source === EMBED_SOURCE && data.v === EMBED_V;
}
let embedHostInstalled = false;
let embedChildInstalled = false;
const pendingInvokes = Object.create(null);
const childCallbackFns = Object.create(null);
function cloneArgsReplacingFunctions(val, registry, seen) {
if (typeof val === "function") {
const id = createUUID();
registry[id] = val;
return { __IScanEmbedCb__: id };
}
if (val === null || typeof val !== "object") {
return val;
}
if (!seen) {
seen = new WeakMap();
}
if (seen.has(val)) {
throw new Error("[IScan embed]: circular reference in arguments");
}
seen.set(val, true);
if (Array.isArray(val)) {
return val.map((item) => cloneArgsReplacingFunctions(item, registry, seen));
}
const out = {};
Object.keys(val).forEach((k) => {
out[k] = cloneArgsReplacingFunctions(val[k], registry, seen);
});
return out;
}
function serializeEmbedParams(params) {
const serialized = [];
const registry = {};
for (let i = 0; i < params.length; i++) {
serialized.push(cloneArgsReplacingFunctions(params[i], registry));
}
return { serialized, registry };
}
function hydrateEmbedParams(params, messageSource, targetOrigin) {
function walk(val) {
if (val && typeof val === "object" && val.__IScanEmbedCb__) {
const cbId = val.__IScanEmbedCb__;
return function embedCbProxy() {
const args = Array.prototype.slice.call(arguments);
messageSource.postMessage(
{
source: EMBED_SOURCE,
v: EMBED_V,
kind: "callback",
cbId,
args,
},
targetOrigin
);
};
}
if (val === null || typeof val !== "object") {
return val;
}
if (Array.isArray(val)) {
return val.map(walk);
}
const out = {};
Object.keys(val).forEach((k) => {
out[k] = walk(val[k]);
});
return out;
}
return params.map(walk);
}
function broadcastScanErrorToEmbedChildren(error) {
if (embedChildSources.size === 0 || error == null || error === "") {
return;
}
embedChildSources.forEach((source) => {
try {
source.postMessage(
{
source: EMBED_SOURCE,
v: EMBED_V,
kind: "forwardScanError",
error,
},
"*"
);
} catch (e) {
}
});
}
function broadcastScanResultToEmbedChildren(result, meta) {
if (embedChildSources.size === 0 || result == null || result === "") {
return;
}
embedChildSources.forEach((source) => {
try {
source.postMessage(
{
source: EMBED_SOURCE,
v: EMBED_V,
kind: "forwardScanResult",
result,
scanSource: meta && meta.source,
},
"*"
);
} catch (e) {
}
});
}
let embedWxProbeScheduled = false;
let embedWxProbeAttempts = 0;
const EMBED_WX_PROBE_MAX = 4;
function shouldScheduleParentWxProbe() {
if (typeof window === "undefined" || !hasDistinctParentWindow()) {
return false;
}
const mode = getConfig("embedProxyMode");
if (mode === false || mode === "local" || mode === "off") {
return false;
}
return true;
}
/** 向父页询问是否微信环境(子 iframe 跨域时本地 UA 可能不可靠) */
function scheduleEmbedWxEnvProbeIfNeeded() {
if (!shouldScheduleParentWxProbe()) {
return;
}
if (embedWxProbeScheduled) {
return;
}
if (embedWxProbeAttempts >= EMBED_WX_PROBE_MAX) {
return;
}
embedWxProbeScheduled = true;
embedWxProbeAttempts++;
ensureEmbedChildListener();
window.parent.postMessage(
{
source: EMBED_SOURCE,
v: EMBED_V,
kind: "probeWxEnv",
id: createUUID(),
},
"*"
);
window.setTimeout(() => {
embedWxProbeScheduled = false;
if (getParentWxEnvReport() !== null || !shouldScheduleParentWxProbe()) {
return;
}
scheduleEmbedWxEnvProbeIfNeeded();
}, 600);
}
function embedChildOnMessage(ev) {
const data = ev.data;
if (!isEmbedMessage(data)) {
return;
}
if (data.kind === "probeWxEnvResult") {
setParentWxEnvReport(!!data.wx);
return;
}
if (data.kind === "forwardScanResult") {
if (typeof data.result === "string") {
const consumed = dispatchEmbedScanResult(data.result, {
source: data.scanSource,
});
if (consumed && resolveUseParentProxy()) {
window.parent.postMessage(
{
source: EMBED_SOURCE,
v: EMBED_V,
kind: "scanResultConsumed",
result: data.result,
},
"*"
);
}
}
return;
}
if (data.kind === "forwardScanError") {
if (typeof data.error === "string") {
dispatchEmbedScanError(data.error);
}
return;
}
if (data.kind === "invokeResult") {
const pending = pendingInvokes[data.id];
if (!pending) {
return;
}
delete pendingInvokes[data.id];
if (data.ok) {
pending.resolve(data.result);
} else {
pending.reject(new Error(data.error || "[IScan embed]: invoke failed"));
}
return;
}
if (data.kind === "callback") {
const fn = childCallbackFns[data.cbId];
if (typeof fn === "function") {
fn.apply(null, data.args || []);
}
}
}
function ensureEmbedChildListener() {
if (embedChildInstalled || typeof window === "undefined") {
return;
}
embedChildInstalled = true;
installScanBeepGestureUnlock();
window.addEventListener("message", embedChildOnMessage);
}
function embedInvoke(methodKey, params) {
ensureEmbedChildListener();
scheduleEmbedWxEnvProbeIfNeeded();
const id = createUUID();
const { serialized, registry } = serializeEmbedParams(params);
Object.keys(registry).forEach((cbId) => {
childCallbackFns[cbId] = registry[cbId];
});
return new Promise((resolve, reject) => {
pendingInvokes[id] = { resolve, reject };
window.parent.postMessage(
{
source: EMBED_SOURCE,
v: EMBED_V,
kind: "invoke",
id,
methodKey,
params: serialized,
},
"*"
);
});
}
function handleEmbedHostInvoke(lib, ev) {
const data = ev.data;
const { id, methodKey, params } = data;
const hydrated = hydrateEmbedParams(params || [], ev.source, ev.origin);
Promise.resolve()
.then(() => _exec(lib, methodKey, ...hydrated))
.then((result) => {
let out = result;
if (out && typeof out.then === "function") {
return out.then((r) => {
out = r;
return out;
});
}
return out;
})
.then((result) => {
ev.source.postMessage(
{
source: EMBED_SOURCE,
v: EMBED_V,
kind: "invokeResult",
id,
methodKey,
ok: true,
result,
},
ev.origin
);
})
.catch((err) => {
ev.source.postMessage(
{
source: EMBED_SOURCE,
v: EMBED_V,
kind: "invokeResult",
id,
methodKey,
ok: false,
error: typeof err === "string" ? err : String((err && err.message) || err),
},
ev.origin
);
});
}
/**
* 在顶层页面注册 iframe 嵌入代理:子页面(嵌入模式)通过 postMessage 调用同一套 SDK。
* 需在加载 SDK 后调用一次,并传入与 exportSDK 相同的 lib通常为 ./_core 默认导出)。
*/
export function installEmbedHost(lib) {
if (embedHostInstalled || typeof window === "undefined") {
return;
}
embedHostInstalled = true;
setEmbedScanResultForwarder(broadcastScanResultToEmbedChildren);
setEmbedScanErrorForwarder(broadcastScanErrorToEmbedChildren);
setEmbedScanHostEnabled(true);
window.addEventListener("message", (ev) => {
const data = ev.data;
if (!isEmbedMessage(data)) {
return;
}
if (data.kind === "probeWxEnv") {
if (!ev.source || ev.source === window) {
return;
}
const wx = readWxLikeEnvFromWindow(window);
ev.source.postMessage(
{
source: EMBED_SOURCE,
v: EMBED_V,
kind: "probeWxEnvResult",
id: data.id,
wx,
},
ev.origin
);
return;
}
if (data.kind === "scanResultConsumed") {
if (!ev.source || ev.source === window) {
return;
}
if (typeof data.result === "string") {
acknowledgeEmbedScanConsumed(data.result);
}
return;
}
if (data.kind !== "invoke") {
return;
}
if (!data.id || !data.methodKey) {
return;
}
if (!ev.source || ev.source === window) {
return;
}
embedChildSources.add(ev.source);
handleEmbedHostInvoke(lib, ev);
});
}
function _exec(target, func, ...params) { function _exec(target, func, ...params) {
let instant = target; let instant = target;
@@ -52,6 +451,39 @@ function freezeObj(obj) {
Object.freeze(obj); Object.freeze(obj);
} }
/**
* 统一调用代理:根据 embedProxyMode + 环境决定走父页面转发或本地 _exec。
*/
function createInvokeTransport(lib, method, methodName, initNames) {
return function IScanInvokeProxy(...params) {
if (resolveUseParentProxy()) {
if (EMBED_LISTENER_METHODS.has(methodName)) {
if (!isReadyCalled() && initNames && initNames.indexOf(method) < 0) {
throw `[IScan]:Can't call the "IScan.${method}" method, because "IScan" not ready, please confirm that "IScan.ready()" has been called. params: ${JSON.stringify(params)}`
}
return _exec(lib, methodName, ...params);
}
if (EMBED_SCAN_GESTURE_METHODS.has(method)) {
unlockScanBeep();
}
return embedInvoke(methodName, params);
}
if (!isReadyCalled() && initNames && initNames.indexOf(method) < 0) {
throw `[IScan]:Can't call the "IScan.${method}" method, because "IScan" not ready, please confirm that "IScan.ready()" has been called. params: ${JSON.stringify(params)}`
}
return _exec(lib, methodName, ...params);
};
}
/**
* 调试:当前解析到的嵌入转发开关(每次读取最新 config
*/
export function getEmbedProxyResolved() {
return resolveUseParentProxy();
}
export { resolveUseParentProxy } from "./services/embedProxy";
export function exportSDK(lib, funcs, ...initNames) { export function exportSDK(lib, funcs, ...initNames) {
let methods = {}; let methods = {};
if (funcs && typeof funcs === 'object') { if (funcs && typeof funcs === 'object') {
@@ -63,13 +495,9 @@ export function exportSDK(lib, funcs, ...initNames) {
Object.keys(methods).forEach(method => { Object.keys(methods).forEach(method => {
let methodItem = methods[method]; let methodItem = methods[method];
let methodName = methodItem && methodItem.method || method; let methodName = methodItem && methodItem.method || method;
hook(library, method, (...params) => { hook(library, method, createInvokeTransport(lib, method, methodName, initNames));
if (!isReadyCalled() && initNames && initNames.indexOf(method) < 0) {
throw `[IScan]:Can't call the "IScan.${method}" method, because "IScan" not ready, please confirm that "IScan.ready()" has been called. params: ${JSON.stringify(params)}`
}
return _exec(lib, methodName, ...params);
});
}); });
scheduleEmbedWxEnvProbeIfNeeded();
freezeObj(library); freezeObj(library);
return library; return library;
} }

View File

@@ -1,15 +1,26 @@
import './polyfill';
import core from "./_core"; import core from "./_core";
import _global from './polyfill/_global'; import _global from './polyfill/_global';
import { exportSDK } from './_export'; import { exportSDK, installEmbedHost } from './_export';
import { setSdkScriptSrc } from './services/web';
if (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) {
setSdkScriptSrc(document.currentScript.src);
}
const IScan = exportSDK(core, null, "config", "setStatusListener", "onScanListener", const IScan = exportSDK(core, null, "config", "setStatusListener", "onScanListener",
"offScanListener", "stopScan", "startScan", "scanImage", "clear"); "offScanListener", "onScanErrorListener", "offScanErrorListener", "stopScan", "startScan", "scanImage", "scanImageFromFile", "clear");
installEmbedHost(core);
function dispatchIScanReady() { function dispatchIScanReady() {
_global.__IScanReady__ && _global.__IScanReady__(); _global.__IScanReady__ && _global.__IScanReady__();
if (!_global.dispatchEvent) {
return;
}
if (typeof Event === "function") { if (typeof Event === "function") {
_global.dispatchEvent(new Event("IScanReady")); _global.dispatchEvent(new Event("IScanReady"));
} else { } else if (typeof document !== "undefined") {
let event = document.createEvent("Event"); let event = document.createEvent("Event");
event.initEvent("IScanReady", true, true); event.initEvent("IScanReady", true, true);
_global.dispatchEvent(event); _global.dispatchEvent(event);

View File

@@ -1,5 +1,12 @@
import { polyfill } from 'es6-promise'; import { polyfill } from 'es6-promise';
// Object.hasOwn (ES2022) — barcode-detector / ZXing 依赖,旧版 WebView 无此方法
if (typeof Object.hasOwn !== 'function') {
Object.hasOwn = function hasOwn(object, key) {
return Object.prototype.hasOwnProperty.call(object, key);
};
}
// Object.assign // Object.assign
if (typeof Object.assign != 'function') { if (typeof Object.assign != 'function') {
// Must be writable: true, enumerable: false, configurable: true // Must be writable: true, enumerable: false, configurable: true

View File

@@ -7,13 +7,23 @@ let _events = {};
let _callbacks = {}; let _callbacks = {};
let _bridge = "__bridge_client__"; let _bridge = "__bridge_client__";
function getWindow() {
if (typeof window === "undefined") {
return null;
}
return window;
}
function getBridgeName() { function getBridgeName() {
return getConfig("bridgeName") ? getConfig("bridgeName") : _bridge; return getConfig("bridgeName") ? getConfig("bridgeName") : _bridge;
} }
function _callRuntime(func, ...options) { function _callRuntime(func, ...options) {
let funcs = func.split('.'); let funcs = func.split('.');
let instant = window; let instant = getWindow();
if (!instant) {
return;
}
while (funcs.length > 1) { while (funcs.length > 1) {
instant = instant[funcs.shift()]; instant = instant[funcs.shift()];
} }
@@ -31,9 +41,13 @@ function onCallback(name, callback) {
} }
function _checkInit() { function _checkInit() {
const win = getWindow();
if (!win) {
return;
}
let methodName = `${getBridgeName()}_handle_callback`; let methodName = `${getBridgeName()}_handle_callback`;
if (!window[methodName]) { if (!win[methodName]) {
window[methodName] = (res) => { win[methodName] = (res) => {
let { method, payload, code, request_id } = toAny(res, {}); let { method, payload, code, request_id } = toAny(res, {});
let data = toAny(payload, {}); let data = toAny(payload, {});
if (request_id) { if (request_id) {
@@ -48,7 +62,9 @@ function _checkInit() {
} }
export function inRuntime() { export function inRuntime() {
return !!window[getBridgeName()] const win = getWindow();
return !!win
&& !!win[getBridgeName()]
&& getConfig("bridgeEnabled") !== false; && getConfig("bridgeEnabled") !== false;
} }

View File

@@ -3,6 +3,13 @@ let _defineConfig = {
} }
let _defConfig = { let _defConfig = {
/**
* iframe / 嵌入场景下是否把调用转发到父页面的同名 SDKpostMessage
* - 'auto'(默认):处于子 frame 时 startScan 等走父页onScanListener 仅注册在 iframe 内,识别结果由父页回传
* - true | 'on' | 'parent':在存在父 window 时强制转发
* - false | 'off' | 'local':始终在本页执行(子页自己要跑扫码时用)
*/
embedProxyMode: 'auto',
} }
let _customConfig = { let _customConfig = {

View File

@@ -0,0 +1,40 @@
/**
* 嵌入场景下由父页探测得到的微信环境结论(子页跨域时无法读 top UA
*/
let parentWxEnvReport = null;
export function setParentWxEnvReport(wx) {
parentWxEnvReport = wx ? true : false;
}
export function getParentWxEnvReport() {
return parentWxEnvReport;
}
/**
* 在给定 window 上探测微信系 WebView公众号 / 部分企业微信)
* 与外层页面是否为同一套 SDK无关依赖 UA / WeixinJSBridge
*/
export function readWxLikeEnvFromWindow(win) {
if (!win) {
return false;
}
try {
const nav = win.navigator;
if (nav) {
const ua = nav.userAgent || "";
if (/micromessenger/i.test(ua)) {
return true;
}
if (/wxwork/i.test(ua)) {
return true;
}
}
if (win.WeixinJSBridge) {
return true;
}
} catch (e) {
// ignore
}
return false;
}

View File

@@ -0,0 +1,37 @@
import { getConfig } from "./config";
/** 是否存在可与 postMessage 交互的父 browsing context自身不是顶层 opener/parent */
export function hasDistinctParentWindow() {
if (typeof window === "undefined") {
return false;
}
try {
return window.parent != null && window.parent !== window;
} catch (e) {
return true;
}
}
/**
* 是否通过父页面 SDK 代理执行(每次调用重新读取 config
*
* embedProxyMode 为 auto默认只要 window.parent !== window处于子 frame
* 所有对外 API含 startScan均走 postMessage 由父页同一套 SDK 执行,避免子页重复跑扫码逻辑。
*/
export function resolveUseParentProxy() {
const mode = getConfig("embedProxyMode");
if (mode === false || mode === "local" || mode === "off") {
return false;
}
const parentOk = hasDistinctParentWindow();
if (!parentOk) {
return false;
}
if (mode === true || mode === "on" || mode === "parent") {
return true;
}
if (mode === "auto" || mode === undefined || mode === null) {
return parentOk;
}
return !!mode;
}

View File

@@ -0,0 +1,24 @@
/** 父页识别到扫码结果时,向嵌入 iframe 转发的回调(由 installEmbedHost 注册) */
let embedScanResultForwarder = null;
/** 父页图片识别失败时,向嵌入 iframe 转发的回调 */
let embedScanErrorForwarder = null;
export function setEmbedScanResultForwarder(fn) {
embedScanResultForwarder = typeof fn === "function" ? fn : null;
}
export function setEmbedScanErrorForwarder(fn) {
embedScanErrorForwarder = typeof fn === "function" ? fn : null;
}
export function forwardEmbedScanResultIfNeeded(result, meta) {
if (embedScanResultForwarder && result != null && result !== "") {
embedScanResultForwarder(result, meta);
}
}
export function forwardEmbedScanErrorIfNeeded(error) {
if (embedScanErrorForwarder && error != null && error !== "") {
embedScanErrorForwarder(error);
}
}

View File

@@ -1,28 +1,144 @@
import { inRuntime, bridgeAsync } from "../bridge"; import { inRuntime, bridgeAsync } from "../bridge";
import { isSupportWebScan, startScanForWeb, stopScanForWeb, isSupportImageScan, startScanForImage, unlockScanBeep } from "../web"; import {
import { isSupportWxScan, startScanForWx } from "../wx"; isSupportWebScan,
startScanForWeb,
stopScanForWeb,
isSupportImageScan,
startScanForImage,
unlockScanBeep,
playScanBeep,
isWebScanImageFallbackEnabled,
canUseWebCameraScan,
shouldSkipWebCameraProbe,
cleanupWebScanResiduals,
detectImageFileForScan
} from "../web";
import { isSupportWxScan, startScanForWx, isWxEnv } from "../wx";
import { startScanner, stopScanner } from "../scanner"; import { startScanner, stopScanner } from "../scanner";
import { getConfig } from "../config"; import { getConfig } from "../config";
import { toAny } from "../../utils/toany"; import { toAny } from "../../utils/toany";
import { printDebug } from "../../utils/logger"; import { printDebug, printWarn } from "../../utils/logger";
import { forwardEmbedScanResultIfNeeded, forwardEmbedScanErrorIfNeeded } from "../embedScanBridge";
let _scan_status = "ready"; let _scan_status = "ready";
let _scan_status_listener = null; let _scan_status_listener = null;
let _scan_listener_list = []; let _scan_listener_list = [];
let _scan_error_listener_list = [];
let _scan_resolve = null; let _scan_resolve = null;
let _scan_closing = false; let _scan_closing = false;
let _scan_next_start_time = 0; let _scan_next_start_time = 0;
let _embed_scan_host_enabled = false;
const SCAN_RESTART_DELAY = 500; const SCAN_RESTART_DELAY = 500;
const BRIDGE_SCAN_TIMEOUT = 5000;
const SCAN_SESSION_TIMEOUT = 90000;
function getScanRestartDelay() { function getScanRestartDelay() {
return toAny(getConfig("scanRestartDelay"), SCAN_RESTART_DELAY); return toAny(getConfig("scanRestartDelay"), SCAN_RESTART_DELAY);
} }
function getBridgeScanTimeout() {
const timeout = getConfig("bridgeScanTimeout");
return typeof timeout === "number" && timeout > 0 ? timeout : BRIDGE_SCAN_TIMEOUT;
}
function getScanSessionTimeout() {
const timeout = getConfig("scanSessionTimeout");
return typeof timeout === "number" && timeout > 0 ? timeout : SCAN_SESSION_TIMEOUT;
}
function withScanSessionTimeout(scanPromise) {
return Promise.race([
scanPromise,
new Promise(resolve => {
setTimeout(() => {
printWarn("scan session timeout");
resolve({ cancel: 1, scanTimeout: true });
}, getScanSessionTimeout());
})
]);
}
function __fallbackScanAfterBridgeFailure(err) {
if (!isScanning()) {
throw err;
}
printWarn("bridge scan unavailable, fallback:", err);
return __startNonBridgeScan(err);
}
function __fallbackScanAfterWxFailure(err) {
if (!isScanning()) {
throw err;
}
printWarn("wx scan unavailable, fallback to web/image scan:", err);
return __startNonBridgeScan(err);
}
function __startNonBridgeScan(err) {
if (isSupportWebScan()) {
return __startWebScan();
}
if (isSupportImageScan()) {
return __startImageScan();
}
throw err;
}
function parseBarcodeString(input) {
// 标准化的类型映射表:将各种变体映射到统一标识
// 这样即使传入 EAN_13、EAN-13、EAN13 都能匹配成功
const normalizedTypeMap = {
// EAN 系列
"ean13": true, "ean-13": true, "ean_13": true,
"ean8": true, "ean-8": true, "ean_8": true,
// UPC 系列
"upca": true, "upc-a": true, "upc_a": true,
"upce": true, "upc-e": true, "upc_e": true,
// CODE 系列
"code128": true, "code-128": true, "code_128": true,
"code39": true, "code-39": true, "code_39": true,
"code93": true, "code-93": true, "code_93": true,
// 其他常见类型
"itf": true, "itf14": true, "itf-14": true, "itf_14": true,
"codabar": true,
"pdf417": true, "pdf-417": true, "pdf_417": true,
"qrcode": true, "qr-code": true, "qr_code": true,
"datamatrix": true, "data-matrix": true, "data_matrix": true,
"aztec": true
};
// 健壮性检查
if (typeof input !== 'string') {
return input;
}
// 按第一个逗号分割
const commaIndex = input.indexOf(',');
if (commaIndex === -1) {
return input;
}
const possibleType = input.substring(0, commaIndex).trim();
const value = input.substring(commaIndex + 1).trim();
// 类型或值为空,返回原值
if (possibleType === "" || value === "") {
return input;
}
// 标准化类型(转小写,便于匹配)
const normalizedType = possibleType.toLowerCase();
// 检查是否为已知的条形码类型(支持多种分隔符变体)
const isKnownType = normalizedTypeMap.hasOwnProperty(normalizedType);
if (isKnownType) {
return value;
} else {
return input;
}
}
function __checkScanner() { function __checkScanner() {
if (_scan_listener_list.length > 0) { if (_scan_listener_list.length > 0 || _embed_scan_host_enabled) {
startScanner((result) => { startScanner((result) => {
__scannerResult(result); result = parseBarcodeString(result);
__scannerResult(result, { source: "scanner", skipBeep: true });
}); });
} else { } else {
stopScanner(); stopScanner();
@@ -40,8 +156,17 @@ function __match(result, match) {
return true; return true;
} }
function __shouldSkipBeep(meta) {
if (meta && meta.skipBeep) {
return true;
}
const source = meta && meta.source;
return source === "scanner" || source === "bridge" || source === "wx";
}
function __result(result) { function __result(result, meta) {
result = parseBarcodeString(result);
forwardEmbedScanResultIfNeeded(result, meta);
let matched = false; let matched = false;
for (let i = 0; i < _scan_listener_list.length; i++) { for (let i = 0; i < _scan_listener_list.length; i++) {
const item = _scan_listener_list[i]; const item = _scan_listener_list[i];
@@ -52,11 +177,76 @@ function __result(result) {
} }
} }
if (matched) { if (matched) {
if (!__shouldSkipBeep(meta)) {
playScanBeep();
}
_scan_next_start_time = Date.now() + getScanRestartDelay(); _scan_next_start_time = Date.now() + getScanRestartDelay();
} }
return matched; return matched;
} }
function normalizeScanError(raw) {
if (typeof raw === "string") {
return raw;
}
if (raw && raw.error != null && raw.error !== "") {
return String(raw.error);
}
if (raw && raw.message) {
return String(raw.message);
}
if (raw == null || raw === "") {
return "";
}
return String(raw);
}
function __scanError(raw, meta) {
const error = normalizeScanError(raw);
if (!error) {
return false;
}
forwardEmbedScanErrorIfNeeded(error);
let matched = false;
for (let i = 0; i < _scan_error_listener_list.length; i++) {
const item = _scan_error_listener_list[i];
if (item.listener && __match(error, item.match)) {
matched = true;
item.listener(Object.assign({
error,
key: item.key
}, meta || {}));
break;
}
}
return matched;
}
function __notifyImageScanFailure(raw, meta) {
if (raw && raw.cancel) {
return false;
}
const error = normalizeScanError(raw);
const notified = __scanError(raw, Object.assign({ source: "image" }, meta || {}));
if (!notified && error) {
printWarn("image scan failed:", error);
}
return notified;
}
function __notifyWebScanFailure(raw, meta) {
if (raw && raw.cancel) {
return false;
}
const payload = raw && raw.error != null ? raw.error : raw;
const error = normalizeScanError(payload);
const notified = __scanError(payload, Object.assign({ source: "web" }, meta || {}));
if (!notified && error) {
printWarn("web scan failed:", error);
}
return notified;
}
function __hasMatchedListener(result) { function __hasMatchedListener(result) {
for (let i = 0; i < _scan_listener_list.length; i++) { for (let i = 0; i < _scan_listener_list.length; i++) {
const item = _scan_listener_list[i]; const item = _scan_listener_list[i];
@@ -105,10 +295,49 @@ function __stopCurrentScan() {
return Promise.resolve(); return Promise.resolve();
} }
function __scannerResult(result) { /**
if (!__hasMatchedListener(result)) { * 父页通过 postMessage 将识别结果投递到嵌入 iframe 时调用(与本地扫码枪/监听同一链路)。
* @returns {boolean} 是否有监听消费了该结果
*/
export function dispatchEmbedScanResult(raw, meta) {
const result =
typeof raw === "string" ? parseBarcodeString(raw) : raw;
return __scannerResult(result, meta);
}
/**
* 父页通过 postMessage 将识别错误投递到嵌入 iframe 时调用。
* @returns {boolean} 是否有监听消费了该错误
*/
export function dispatchEmbedScanError(raw) {
const error = normalizeScanError(raw);
return __scanError(error, { source: "image" });
}
/**
* 嵌入 iframe 已消费扫码结果时通知父页结束当前识别(关闭摄像头/UI 等)。
*/
export function acknowledgeEmbedScanConsumed(raw) {
const result =
typeof raw === "string" ? parseBarcodeString(raw) : raw;
if (!isScanning()) {
return; return;
} }
const resolve = __finishScan();
_scan_closing = true;
_scan_next_start_time = Date.now() + getScanRestartDelay();
__stopCurrentScan().then(() => {
setTimeout(() => {
_scan_closing = false;
}, 0);
});
resolve && resolve({ result });
}
function __scannerResult(result, meta) {
if (!__hasMatchedListener(result)) {
return __result(result, meta);
}
if (isScanning()) { if (isScanning()) {
const resolve = __finishScan(); const resolve = __finishScan();
_scan_closing = true; _scan_closing = true;
@@ -117,26 +346,26 @@ function __scannerResult(result) {
_scan_closing = false; _scan_closing = false;
}, 0); }, 0);
}); });
__result(result); const matched = __result(result, meta);
resolve && resolve({ resolve && resolve({
result result
}); });
return; return matched;
} }
__result(result); return __result(result, meta);
} }
function __startBridgeScan() { function __startBridgeScan() {
return bridgeAsync("startScan", { return bridgeAsync("startScan", {
closeable: true closeable: true
}).then(resp => { }, getBridgeScanTimeout()).then(resp => {
if (!isScanning()) { if (!isScanning()) {
return resp; return resp;
} }
if (!resp || !resp.result) { if (!resp || !resp.result) {
return resp; return resp;
} }
if (__result(resp.result)) { if (__result(resp.result, { source: "bridge" })) {
return resp; return resp;
} }
if (isScanning()) { if (isScanning()) {
@@ -145,12 +374,12 @@ function __startBridgeScan() {
return resp; return resp;
}).catch(err => { }).catch(err => {
if (!isScanning()) { if (!isScanning()) {
return err; throw err;
} }
if (!err || !err.result) { if (!err || !err.result) {
return err; throw err;
} }
if (__result(err.result)) { if (__result(err.result, { source: "bridge" })) {
return err; return err;
} }
if (isScanning()) { if (isScanning()) {
@@ -168,29 +397,62 @@ function __startWxScan() {
if (!isScanning()) { if (!isScanning()) {
return resp; return resp;
} }
if (resp && resp.error && !resp.result) {
throw resp.error;
}
if (!resp || !resp.result) { if (!resp || !resp.result) {
return resp; return resp;
} }
if (__result(resp.result)) { if (__result(resp.result, { source: "wx" })) {
return resp; return resp;
} }
if (isScanning()) { return resp;
return __startWxScan(); }).catch(err => {
if (!isScanning()) {
throw err;
} }
if (err && err.result) {
if (__result(err.result, { source: "wx" })) {
return err;
}
return err;
}
return __fallbackScanAfterWxFailure(err);
});
}
function __startWebScan(useImageScan = false) {
return startScanForWeb(__result, __scanError).then(resp => {
if (!isScanning()) {
return resp;
}
if (!resp || !resp.result) {
if (resp && resp.success === false) {
__notifyWebScanFailure(resp);
}
return resp;
}
__result(resp.result);
return resp; return resp;
}).catch(err => { }).catch(err => {
if (!isScanning()) { if (!isScanning()) {
return err; return err;
} }
if (!err || !err.result) { if (err && err.cancel) {
return err; return err;
} }
if (__result(err.result)) { if (err && err.scanTimeout) {
return err; return err;
} }
if (isScanning()) { if (err && err.imageFallbackUsed) {
return __startWxScan(); __notifyWebScanFailure(err);
return err;
} }
if (isWebScanImageFallbackEnabled() && useImageScan) {
return __startImageScan();
}
__notifyWebScanFailure(err);
printWarn("web scan failed:", err);
return err; return err;
}); });
} }
@@ -200,29 +462,27 @@ function __startImageScan() {
if (!isScanning()) { if (!isScanning()) {
return resp; return resp;
} }
if (resp && resp.cancel) {
return resp;
}
if (!resp || !resp.result) { if (!resp || !resp.result) {
__notifyImageScanFailure(resp);
return resp; return resp;
} }
if (__result(resp.result)) { __result(resp.result);
return resp;
}
if (isScanning()) {
return __startImageScan();
}
return resp; return resp;
}).catch(err => { }).catch(err => {
if (!isScanning()) { if (!isScanning()) {
return err; return err;
} }
if (!err || !err.result) { if (err && err.cancel) {
return err; return err;
} }
if (__result(err.result)) { if (err && err.result) {
__result(err.result);
return err; return err;
} }
if (isScanning()) { __notifyImageScanFailure(err);
return __startImageScan();
}
return err; return err;
}); });
} }
@@ -231,12 +491,26 @@ export function isScanning() {
return _scan_status === "scanning"; return _scan_status === "scanning";
} }
export function setEmbedScanHostEnabled(enabled) {
const nextEnabled = enabled !== false;
if (_embed_scan_host_enabled === nextEnabled) {
return;
}
_embed_scan_host_enabled = nextEnabled;
__checkScanner();
}
export function clear() { export function clear() {
for (let i = 0; i < _scan_listener_list.length; i++) { for (let i = 0; i < _scan_listener_list.length; i++) {
const item = _scan_listener_list[i]; const item = _scan_listener_list[i];
item.cancel(); item.cancel();
} }
_scan_listener_list.length = 0; _scan_listener_list.length = 0;
for (let i = 0; i < _scan_error_listener_list.length; i++) {
const item = _scan_error_listener_list[i];
item.cancel();
}
_scan_error_listener_list.length = 0;
__checkScanner(); __checkScanner();
} }
@@ -300,6 +574,68 @@ export function offScanListener(listener) {
__checkScanner(); __checkScanner();
} }
function createScanErrorListenerItem(listener, key, match, level) {
const item = {
key,
match: match || "",
level: level || 0,
listener: listener,
cancel: () => {
const index = _scan_error_listener_list.indexOf(item);
if (index !== -1) {
const items = _scan_error_listener_list.splice(index, 1);
for (let i = 0; i < items.length; i++) {
const removed = items[i];
removed.listener && removed.listener({ cancel: 1, key: removed.key });
}
}
}
};
return item;
}
export function onScanErrorListener(listener, key, match, level) {
if (!key || typeof key !== 'string') {
return;
}
if (typeof listener !== 'function') {
return;
}
let item = null;
for (let i = 0; i < _scan_error_listener_list.length; i++) {
const _i = _scan_error_listener_list[i];
if (_i.key === key) {
item = _i;
break;
}
}
if (item) {
item.level = level;
item.match = match;
item.listener = listener;
} else {
item = createScanErrorListenerItem(listener, key, match, level);
_scan_error_listener_list.push(item);
}
_scan_error_listener_list.sort((a, b) => b.level - a.level);
return item;
}
export function offScanErrorListener(listener) {
for (let i = 0; i < _scan_error_listener_list.length; i++) {
const _i = _scan_error_listener_list[i];
if (typeof listener === 'string') {
if (_i.key === listener) {
_i.cancel();
break;
}
} else if (_i.listener === listener) {
_i.cancel();
break;
}
}
}
export function setStatusListener(listener) { export function setStatusListener(listener) {
if (typeof listener !== 'function') { if (typeof listener !== 'function') {
return; return;
@@ -312,11 +648,15 @@ export function getStatus() {
} }
export function stopScan() { export function stopScan() {
cleanupWebScanResiduals();
if (!isScanning()) { if (!isScanning()) {
return; return;
} }
const resolve = __finishScan();
_scan_closing = true;
__stopCurrentScan().then(() => { __stopCurrentScan().then(() => {
__closed(); resolve && resolve({ cancel: 1 });
_scan_closing = false;
}); });
} }
@@ -324,6 +664,7 @@ export function startScan() {
if (isScanning() || _scan_closing || Date.now() < _scan_next_start_time) { if (isScanning() || _scan_closing || Date.now() < _scan_next_start_time) {
return; return;
} }
unlockScanBeep();
Promise.resolve().then(() => { Promise.resolve().then(() => {
__scanning(); __scanning();
let scannerPromise = new Promise(resolve => { let scannerPromise = new Promise(resolve => {
@@ -331,46 +672,23 @@ export function startScan() {
}); });
let scanPromise = Promise.resolve(); let scanPromise = Promise.resolve();
if (inRuntime()) { if (inRuntime()) {
scanPromise = __startBridgeScan(); scanPromise = __startBridgeScan().catch(__fallbackScanAfterBridgeFailure);
} else if (isSupportWxScan()) { } else if (isSupportWxScan()) {
scanPromise = __startWxScan(); scanPromise = __startWxScan();
} else if (isSupportWebScan()) { } else if (isSupportWebScan()) {
unlockScanBeep(); scanPromise = __startWebScan(true);
scanPromise = startScanForWeb(getConfig("webScanCanvasStyle"), __result);
} else if (isSupportImageScan()) { } else if (isSupportImageScan()) {
scanPromise = __startImageScan(); scanPromise = __startImageScan();
} else { } else {
printDebug("Not support scanner"); printWarn("Not support scanner");
} }
return Promise.race([scanPromise, scannerPromise]); return withScanSessionTimeout(Promise.race([scanPromise, scannerPromise]));
}).finally(() => { }).finally(() => {
_scan_resolve = null; _scan_resolve = null;
__closed(); __closed();
}); });
} }
export function scanVideo() {
if (!isSupportWebScan()) {
printDebug("Not support video scanner");
return;
}
if (isScanning() || _scan_closing || Date.now() < _scan_next_start_time) {
return;
}
unlockScanBeep();
Promise.resolve().then(() => {
__scanning();
return startScanForWeb(getConfig("webScanCanvasStyle"), __result).then(resp => {
if (resp && resp.result) {
__result(resp.result);
}
throw resp.error;
}).catch(err => { });
}).finally(() => {
__closed();
});
}
export function scanImage() { export function scanImage() {
if (!isSupportImageScan()) { if (!isSupportImageScan()) {
printDebug("Not support image scanner"); printDebug("Not support image scanner");
@@ -379,15 +697,85 @@ export function scanImage() {
if (isScanning() || _scan_closing || Date.now() < _scan_next_start_time) { if (isScanning() || _scan_closing || Date.now() < _scan_next_start_time) {
return; return;
} }
Promise.resolve().then(() => { unlockScanBeep();
__scanning(); __scanning();
return startScanForImage().then(resp => { withScanSessionTimeout(__startImageScan()).catch(err => {
if (resp && resp.result) { if (err && err.cancel) {
__result(resp.result); return;
} }
throw resp.error; __notifyImageScanFailure(err);
}).catch(err => { });
}).finally(() => { }).finally(() => {
__closed(); __closed();
}); });
} }
/** 由原生/业务传入已选图片 File避免 WebView 无法从 input.files 取文件 */
export function scanImageFromFile(file) {
if (!isSupportImageScan()) {
printDebug("Not support image scanner");
return;
}
if (isScanning() || _scan_closing || Date.now() < _scan_next_start_time) {
return;
}
unlockScanBeep();
cleanupWebScanResiduals();
__scanning();
withScanSessionTimeout(
detectImageFileForScan(file).then(resp => {
if (!isScanning()) {
return resp;
}
if (resp && resp.cancel) {
return resp;
}
if (!resp || !resp.result) {
__notifyImageScanFailure(resp);
return resp;
}
__result(resp.result);
return resp;
})
).catch(err => {
if (err && err.cancel) {
return;
}
__notifyImageScanFailure(err);
}).finally(() => {
__closed();
});
}
export const supportList = [
{
name: "bridge",
get support() {
return !!inRuntime();
}
},
{
name: "wx",
get support() {
return !!isSupportWxScan();
}
},
{
name: "web",
get support() {
if (shouldSkipWebCameraProbe()) {
return false;
}
return !!canUseWebCameraScan();
}
},
{
name: "image",
get support() {
return !!isSupportImageScan();
}
},
{
name: "scanner",
support: true
}
];

View File

@@ -6,6 +6,13 @@ let _scannerLastInputTime = 0;
const SCANNER_INPUT_INTERVAL = 100; const SCANNER_INPUT_INTERVAL = 100;
function getWindow() {
if (typeof window === "undefined") {
return null;
}
return window;
}
function clearScannerValue() { function clearScannerValue() {
_scannerValue = ""; _scannerValue = "";
_scannerLastInputTime = 0; _scannerLastInputTime = 0;
@@ -27,7 +34,21 @@ function delayClearScannerValue() {
function normalizeScannerValue(value) { function normalizeScannerValue(value) {
return value.replace(/[\uFF01-\uFF5E]/g, char => { return value.replace(/[\uFF01-\uFF5E]/g, char => {
return String.fromCharCode(char.charCodeAt(0) - 0xFEE0); return String.fromCharCode(char.charCodeAt(0) - 0xFEE0);
}).replace(/\u3002/g, "."); }).replace(/\u3002/g, ".")
.replace(/\u2014/g, "_");
}
function isInputElementFocused() {
const win = getWindow();
const activeElement = win && win.document && win.document.activeElement;
if (!activeElement || activeElement === win.document.body || activeElement === win.document.documentElement) {
return false;
}
const tagName = activeElement.tagName && activeElement.tagName.toLowerCase();
if (tagName === "input" || tagName === "textarea" || tagName === "select") {
return true;
}
return activeElement.isContentEditable === true;
} }
function stopScannerEvent(event) { function stopScannerEvent(event) {
@@ -39,6 +60,10 @@ function onScannerKeydown(event) {
if (_scannerStatus !== "scanning") { if (_scannerStatus !== "scanning") {
return; return;
} }
if (isInputElementFocused()) {
clearScannerValue();
return;
}
if (event.ctrlKey || event.metaKey || event.altKey) { if (event.ctrlKey || event.metaKey || event.altKey) {
return; return;
} }
@@ -69,13 +94,17 @@ export function startScanner(callback){
if (!callback || typeof callback !== "function") { if (!callback || typeof callback !== "function") {
return; return;
} }
const win = getWindow();
if (!win) {
return;
}
_scannerCallback = callback; _scannerCallback = callback;
if (_scannerStatus === "scanning") { if (_scannerStatus === "scanning") {
return; return;
} }
_scannerStatus = "scanning"; _scannerStatus = "scanning";
clearScannerValue(); clearScannerValue();
window.addEventListener("keydown", onScannerKeydown); win.addEventListener("keydown", onScannerKeydown);
} }
export function stopScanner(){ export function stopScanner(){
@@ -85,5 +114,6 @@ export function stopScanner(){
_scannerStatus = "ready"; _scannerStatus = "ready";
_scannerCallback = null; _scannerCallback = null;
clearScannerValue(); clearScannerValue();
window.removeEventListener("keydown", onScannerKeydown); const win = getWindow();
} win && win.removeEventListener("keydown", onScannerKeydown);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,6 @@
import { getConfig } from "../config"; import { getConfig } from "../config";
import { readWxLikeEnvFromWindow, getParentWxEnvReport } from "../embedEnvProbe";
import { resolveUseParentProxy } from "../embedProxy";
import { request } from "../../utils/request"; import { request } from "../../utils/request";
import { toAny } from "../../utils/toany"; import { toAny } from "../../utils/toany";
@@ -9,10 +11,13 @@ let _wxReadyPromise = null;
let _wxReady = false; let _wxReady = false;
function getWx() { function getWx() {
if (typeof window === "undefined") { if (typeof wx !== "undefined") {
return null; return wx;
} }
return window.wx; if (typeof window !== "undefined") {
return window.wx;
}
return null;
} }
function loadWxScript() { function loadWxScript() {
@@ -43,6 +48,27 @@ function loadWxScript() {
}); });
} }
/** 微信 JS-SDK 签名 URL嵌入且走父页代理时用父页地址与微信内打开的页面一致可配置 wxJssdkSignatureUrl 覆盖 */
function getPageUrlForWxJssdkSignature() {
const override = getConfig("wxJssdkSignatureUrl");
if (override) {
return String(override).split("#")[0];
}
if (resolveUseParentProxy()) {
try {
if (typeof window !== "undefined" && window.parent && window.parent !== window) {
return window.parent.location.href.split("#")[0];
}
} catch (e) {
// 跨域父页不可读 location由调用方配置 wxJssdkSignatureUrl
}
}
if (typeof window === "undefined") {
return "";
}
return window.location.href.split("#")[0];
}
function fetchWxConfig() { function fetchWxConfig() {
let initWechatJssdk = toAny(getConfig("initWechatJssdk"), {}); let initWechatJssdk = toAny(getConfig("initWechatJssdk"), {});
if (!!initWechatJssdk.sdkConfig) { if (!!initWechatJssdk.sdkConfig) {
@@ -56,7 +82,7 @@ function fetchWxConfig() {
url: apiUrl, url: apiUrl,
method: "GET", method: "GET",
data: { data: {
url: window.location.href.split("#")[0] url: getPageUrlForWxJssdkSignature()
} }
}).then(res => { }).then(res => {
let data = toAny(res.data, {}); let data = toAny(res.data, {});
@@ -74,16 +100,39 @@ function fetchWxConfig() {
} }
export function isWxEnv() { export function isWxEnv() {
return typeof navigator !== "undefined" if (readWxLikeEnvFromWindow(typeof window !== "undefined" ? window : null)) {
&& /micromessenger/i.test(navigator.userAgent || ""); return true;
}
if (typeof window !== "undefined") {
try {
if (window.top && window.top !== window && readWxLikeEnvFromWindow(window.top)) {
return true;
}
} catch (e) {
// 跨域不可读 top依赖父页 probeWxEnv / embedEnvProbe 缓存
}
}
if (resolveUseParentProxy() && getParentWxEnvReport() === true) {
return true;
}
return false;
}
export function isWxMiniProgramEnv() {
const wx = getWx();
return !!(wx && wx.scanCode);
} }
export function isSupportWxScan() { export function isSupportWxScan() {
const wx = getWx(); const wx = getWx();
return isWxEnv() if (isWxMiniProgramEnv()) {
&& _wxReady return true;
&& wx }
&& wx.scanQRCode; // 嵌入且 API 走父页时,子页未必初始化 wx只要识别为微信环境即视为支持实际能力由父页 SDK 决定)
if (resolveUseParentProxy() && isWxEnv()) {
return true;
}
return !!(isWxEnv() && _wxReady && wx && wx.scanQRCode);
} }
export function initWxJssdk() { export function initWxJssdk() {
@@ -128,7 +177,73 @@ export function initWxJssdk() {
return _wxReadyPromise; return _wxReadyPromise;
} }
function getWxScanErrorMessage(err) {
if (!err) {
return "扫码失败";
}
let errorMsg = err.errMsg || "扫码失败";
if (typeof err === "object") {
const errorDetails = [];
if (err.errCode) {
errorDetails.push("错误代码: " + err.errCode);
}
if (err.errDesc) {
errorDetails.push("错误描述: " + err.errDesc);
}
try {
const errorStr = JSON.stringify(err, null, 2);
if (errorStr && errorStr !== "{}" && errorStr !== "{\"errMsg\":\"" + errorMsg + "\"}") {
errorDetails.push(errorStr);
} else {
errorDetails.push(errorMsg);
}
} catch (e) {
}
if (errorDetails.length) {
errorMsg = errorDetails.join("\n");
}
}
return errorMsg;
}
function startScanForWxMiniProgram(options) {
return new Promise(resolve => {
const wx = getWx();
if (!wx || !wx.scanCode) {
resolve({
success: false,
error: "微信小程序API不可用"
});
return;
}
const {
scanType = ["qrCode", "barCode"],
onlyFromCamera = false
} = options || {};
wx.scanCode({
onlyFromCamera,
scanType,
success: res => {
resolve({
success: true,
result: res.result,
code: res.result
});
},
fail: err => {
resolve({
success: false,
error: getWxScanErrorMessage(err)
});
}
});
});
}
export function startScanForWx(options) { export function startScanForWx(options) {
if (isWxMiniProgramEnv()) {
return startScanForWxMiniProgram(options);
}
return initWxJssdk().then(() => { return initWxJssdk().then(() => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const { const {

186
types/index.d.ts vendored
View File

@@ -6,6 +6,18 @@ interface ScanConfigOptions {
* 扫码重启延迟单位毫秒默认500ms * 扫码重启延迟单位毫秒默认500ms
*/ */
scanRestartDelay?: number, scanRestartDelay?: number,
/**
* iframe 场景下是否将 API 调用转发到父页面同名 SDKpostMessage
* - `'auto'`(默认):处于子 frame 时 `startScan` 等由父页执行;`onScanListener` 仅注册在 iframe 内,识别结果由父页 `postMessage` 回传
* - `true` / `'on'` / `'parent'`:存在父 window 时强制转发
* - `false` / `'off'` / `'local'`:始终在本页执行(子页自己要跑扫码时用)
*/
embedProxyMode?: 'auto' | boolean | 'on' | 'off' | 'local' | 'parent',
/**
* 请求微信 JS-SDK 签名时使用的页面 URL不含 hash
* 跨域 iframe 无法读取父页地址时需手动设为当前微信内打开的页面链接。
*/
wxJssdkSignatureUrl?: string,
/** /**
* 桥接是否启用,默认启用 * 桥接是否启用,默认启用
*/ */
@@ -33,6 +45,58 @@ interface ScanConfigOptions {
* 2. 结束扫码的方法名称为stopScan * 2. 结束扫码的方法名称为stopScan
*/ */
bridgeName?: string, bridgeName?: string,
/**
* 桥接扫码超时(毫秒),超时后回退 Web/图片识别,默认 5000
*/
bridgeScanTimeout?: number,
/**
* 是否允许 H5 摄像头扫码true 强制开启(仍需有媒体 APIfalse 强制关闭
*/
webScanCameraEnabled?: boolean,
/**
* @deprecated 请用 webScanCameraEnabled保留兼容
*/
webScanCameraInWechat?: boolean,
/**
* @deprecated 摄像头权限已后置到 startScan请用 webScanVideoAccessTimeout
*/
webScanCameraProbeTimeout?: number,
/**
* startScan 走 Web 摄像头前是否展示权限说明弹窗,默认 true
*/
webScanCameraPermissionDialogEnabled?: boolean,
/**
* 摄像头权限说明弹窗标题
*/
webScanCameraPermissionTitle?: string,
/**
* 摄像头权限说明弹窗正文
*/
webScanCameraPermissionMessage?: string,
/**
* 摄像头权限说明弹窗确认按钮文案
*/
webScanCameraPermissionConfirmText?: string,
/**
* 摄像头权限说明弹窗取消按钮文案
*/
webScanCameraPermissionCancelText?: string,
/**
* 选图后延迟读取 file 对象(毫秒),微信/安卓 WebView 默认 100
*/
webScanFileReadDelay?: number,
/**
* 强制使用 WASM ponyfill 识别(微信/部分 WebView 建议开启,默认微信内自动开启)
*/
webScanPreferPonyfill?: boolean,
/**
* WASM 文件完整 URL未配置时相对 SDK 脚本地址解析 lib/reader.wasm
*/
webScanWasmUrl?: string,
/**
* WASM 基准路径SDK 脚本 URL 或目录);用于 async/defer 加载时修正 reader.wasm 路径
*/
webScanWasmBaseUrl?: string,
/** /**
* webScan是否启用默认启用 * webScan是否启用默认启用
*/ */
@@ -42,9 +106,29 @@ interface ScanConfigOptions {
*/ */
webScanCanvasEnabled?: boolean, webScanCanvasEnabled?: boolean,
/** /**
* 网页扫码canvas样式默认position: fixed; width: 300px; height: 300px; top: 0; left: 0; z-index: 9999; * 网页扫码canvas样式默认PC 为左上角固定 300×300移动端为 min(视口宽, 视口高) 的正方形,固定在左上角;z-index: 9999
*/ */
webScanCanvasStyle?: string, webScanCanvasStyle?: string,
/**
* 网页扫码canvas关闭按钮样式默认显示在canvas右上角
*/
webScanCloseButtonStyle?: string,
/**
* 网页扫码 canvas 根元素的 class便于配合外部样式表定制布局与外观
*/
webScanCanvasClass?: string,
/**
* 网页扫码关闭按钮的 class
*/
webScanCloseButtonClass?: string,
/**
* Canvas 开启且支持图片识别时关闭按钮下方的「选图」按钮样式fixed 定位基准由 SDK 计算,可与关闭按钮一致覆盖)
*/
webScanPickImageButtonStyle?: string,
/**
* 网页扫码选图按钮的 class
*/
webScanPickImageButtonClass?: string,
/** /**
* 网页扫码类型,默认支持二维码和条码 * 网页扫码类型,默认支持二维码和条码
*/ */
@@ -58,13 +142,60 @@ interface ScanConfigOptions {
*/ */
webScanVideoMirrorVertical?: boolean, webScanVideoMirrorVertical?: boolean,
/** /**
* 网页扫码成功提示音地址,默认使用内置提示音 * 摄像头不可用(如部分安卓内置浏览器禁用 getUserMedia时是否自动回退为拍照/选图识别,默认启用
*/ */
webScanBeepAudio?: string, webScanImageFallbackOnVideoError?: boolean,
/** /**
* 网页扫码成功提示音是否启用,默认启用 * 打开摄像头超时时间(毫秒),超时后触发图片回退,默认 10000
*/ */
webScanBeepEnabled?: boolean, webScanVideoAccessTimeout?: number,
/**
* 摄像头已打开但长时间无画面时触发图片回退(毫秒),默认 8000
*/
webScanVideoReadyTimeout?: number,
/**
* 图片/回退识别时 detect 超时(毫秒),默认 15000
*/
webScanDetectTimeout?: number,
/**
* 图片识别是否优先使用 ZXing ponyfill原生 detect 在部分 WebView 可能卡住),默认 true
*/
webScanImagePreferPonyfill?: boolean,
/**
* 移动端图片回退是否使用 capture 拍照false 为相册选图,兼容性更好),默认 false
*/
webScanImagePreferCapture?: boolean,
/**
* 图片识别是否优先 canvas 解码(安卓 WebView 建议开启),默认在安卓/微信内自动开启
*/
webScanImageDetectPreferCanvas?: boolean,
/**
* ZXing WASM 加载超时(毫秒),默认 20000
*/
webScanPrepareTimeout?: number,
/**
* 选图等待超时(毫秒),默认 120000
*/
webScanChooseImageTimeout?: number,
/**
* 选图方式button=显示「选择图片」按钮(安卓/微信默认需用户点击auto=自动弹出系统选图
*/
webScanImagePickerMode?: 'auto' | 'button',
webScanImagePickerTitle?: string,
webScanImagePickerButtonText?: string,
webScanImagePickerCancelText?: string,
/**
* 单次扫码会话超时(毫秒),超时后状态恢复 ready默认 90000
*/
scanSessionTimeout?: number,
/**
* 扫码成功提示音地址,默认使用内置提示音;任意识别模式匹配成功时播放
*/
scanBeepAudio?: string,
/**
* 扫码成功是否播放提示音,默认启用;任意识别模式匹配成功时生效
*/
scanBeepEnabled?: boolean,
/** /**
* 微信JSSDK配置微信环境才会生效配置后会自动初始化微信JSSDK * 微信JSSDK配置微信环境才会生效配置后会自动初始化微信JSSDK
*/ */
@@ -117,6 +248,16 @@ interface ScanResult {
key: string key: string
} }
/**
* 扫码错误
*/
interface ScanErrorInfo {
error: string,
key: string,
source?: string,
cancel?: number
}
/** /**
* 监听key * 监听key
*/ */
@@ -153,6 +294,22 @@ type ScanStatus = "scanning" | "ready";
*/ */
type ScanResultCallback = (result: ScanResult) => any; type ScanResultCallback = (result: ScanResult) => any;
/**
* 监听扫码错误回调
*/
type ScanErrorCallback = (error: ScanErrorInfo) => any;
/**
* 扫码错误监听信息
*/
interface ScanErrorListenerInfo {
key?: string;
match?: string;
level?: number;
listener: ScanErrorCallback;
cancel: () => void;
}
/** /**
* 监听状态回调 * 监听状态回调
*/ */
@@ -183,6 +340,19 @@ interface IScan {
* @param callback 监听回调或监听key * @param callback 监听回调或监听key
*/ */
offScanListener(callback: ScanResultCallback | string): void; offScanListener(callback: ScanResultCallback | string): void;
/**
* 添加监听扫码错误(如图片识别失败)
* @param callback 错误回调
* @param key 监听 key
* @param match 可选正则,匹配 error 文本后回调
* @param level 优先级
*/
onScanErrorListener(callback: ScanErrorCallback, key: string, match?: string, level?: number): ScanErrorListenerInfo;
/**
* 取消监听扫码错误
* @param callback 监听回调,或监听 key
*/
offScanErrorListener(callback: ScanErrorCallback | string): void;
/** /**
* 获取扫码状态 * 获取扫码状态
* @returns ScanStatus * @returns ScanStatus
@@ -196,14 +366,12 @@ interface IScan {
* 开启扫码 * 开启扫码
*/ */
startScan(): void; startScan(): void;
/**
* 开启视频扫码
*/
scanVideo(): void;
/** /**
* 选择图片进行识别 * 选择图片进行识别
*/ */
scanImage(): void; scanImage(): void;
/** 由业务/原生传入已选图片 File 识别WebView input.files 异常时使用) */
scanImageFromFile(file: File | Blob): void;
/** /**
* 清除全部监听 * 清除全部监听
*/ */

View File

@@ -30,6 +30,7 @@ const config = {
}), }),
new CpWebpackPlugin([ new CpWebpackPlugin([
{ from: path.resolve('./types'), to: path.resolve('./dist') }, { from: path.resolve('./types'), to: path.resolve('./dist') },
{ from: path.resolve('./README.md'), to: path.resolve('./dist/index.md') },
{ from: zxingReaderWasm, to: path.resolve('./dist/lib/reader.wasm') }, { from: zxingReaderWasm, to: path.resolve('./dist/lib/reader.wasm') },
]) ])
], ],