fix(weixin-sdk): fix iOS scanQRCode false camera permission error
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -26,6 +26,9 @@ function formatScanError(e: unknown): string {
|
|||||||
if (/invalid signature/i.test(msg)) {
|
if (/invalid signature/i.test(msg)) {
|
||||||
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名为 user.runxian.top,并刷新页面后重试';
|
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名为 user.runxian.top,并刷新页面后重试';
|
||||||
}
|
}
|
||||||
|
if (/offline verifying|权限验证中|接口未就绪/i.test(msg)) {
|
||||||
|
return `${msg}(可在 URL 后加 ?wxdebug=1 开启 JSSDK 调试查看详情)`;
|
||||||
|
}
|
||||||
return msg;
|
return msg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getRuntimePlatform } from './env';
|
import { getRuntimePlatform } from './env';
|
||||||
import { ensureJssdkReady, normalizeJssdkPageUrl } from './jssdk';
|
import { ensureJssdkReady, normalizeJssdkPageUrl, getJssdkSignUrl } from './jssdk';
|
||||||
import type { WeixinSdkConfig } from './types';
|
import type { WeixinSdkConfig } from './types';
|
||||||
|
|
||||||
export type ChooseWechatImageOptions = {
|
export type ChooseWechatImageOptions = {
|
||||||
@@ -84,14 +84,13 @@ export async function chooseWechatImages(
|
|||||||
const sourceType = options.sourceType ?? ['album', 'camera'];
|
const sourceType = options.sourceType ?? ['album', 'camera'];
|
||||||
|
|
||||||
if (platform === 'wechat-h5') {
|
if (platform === 'wechat-h5') {
|
||||||
const pageUrl = typeof window !== 'undefined' ? normalizeJssdkPageUrl(window.location.href) : '';
|
const pageUrl = typeof window !== 'undefined' ? getJssdkSignUrl() : '';
|
||||||
const sourceTypeKey = sourceType.join(',');
|
const sourceTypeKey = sourceType.join(',');
|
||||||
try {
|
try {
|
||||||
await ensureJssdkReady({
|
await ensureJssdkReady({
|
||||||
apiBase: config.apiBase ?? '/api/v1',
|
apiBase: config.apiBase ?? '/api/v1',
|
||||||
clientApp: config.clientApp,
|
clientApp: config.clientApp,
|
||||||
getAccessToken: config.getAccessToken,
|
getAccessToken: config.getAccessToken,
|
||||||
url: pageUrl,
|
|
||||||
jsApiList: ['chooseImage', 'getLocalImgData'],
|
jsApiList: ['chooseImage', 'getLocalImgData'],
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,3 +1,15 @@
|
|||||||
|
/** 是否 iOS 设备(不含微信开发者工具模拟器特殊处理) */
|
||||||
|
export function isIosDevice(): boolean {
|
||||||
|
if (typeof navigator === 'undefined') return false;
|
||||||
|
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as Window & { MSStream?: unknown }).MSStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否微信开发者工具 */
|
||||||
|
export function isWechatDevTools(): boolean {
|
||||||
|
if (typeof navigator === 'undefined') return false;
|
||||||
|
return /wechatdevtools/i.test(navigator.userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
/** 是否微信内置浏览器 */
|
/** 是否微信内置浏览器 */
|
||||||
export function isWechatBrowser(): boolean {
|
export function isWechatBrowser(): boolean {
|
||||||
if (typeof navigator === 'undefined') return false;
|
if (typeof navigator === 'undefined') return false;
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
export { isWechatBrowser, isMiniProgram, getRuntimePlatform } from './env';
|
export { isWechatBrowser, isMiniProgram, getRuntimePlatform } from './env';
|
||||||
export { getRouterBasename, toAppPath, isOnAppPath } from './app-path';
|
export { getRouterBasename, toAppPath, isOnAppPath } from './app-path';
|
||||||
export { initWechatJssdk, ensureJssdkReady, isJssdkReady, normalizeJssdkPageUrl, stripOAuthParamsFromLocation } from './jssdk';
|
export {
|
||||||
|
initWechatJssdk,
|
||||||
|
ensureJssdkReady,
|
||||||
|
isJssdkReady,
|
||||||
|
normalizeJssdkPageUrl,
|
||||||
|
getJssdkSignUrl,
|
||||||
|
captureIosJssdkEntryUrl,
|
||||||
|
stripOAuthParamsFromLocation,
|
||||||
|
} from './jssdk';
|
||||||
|
export { formatScanFailMessage } from './scan';
|
||||||
export {
|
export {
|
||||||
getWechatLocation,
|
getWechatLocation,
|
||||||
getWechatLocationDetailed,
|
getWechatLocationDetailed,
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import type { WechatJssdkConfig } from '@dukang/shared-types';
|
import type { WechatJssdkConfig } from '@dukang/shared-types';
|
||||||
|
import { isIosDevice, isWechatBrowser, isWechatDevTools } from './env';
|
||||||
import { DEFAULT_JS_API_LIST } from './types';
|
import { DEFAULT_JS_API_LIST } from './types';
|
||||||
|
|
||||||
const JSSDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
|
const JSSDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
|
||||||
|
const IOS_ENTRY_URL_KEY = 'dukang_wx_ios_entry_url';
|
||||||
|
|
||||||
let scriptPromise: Promise<void> | null = null;
|
let scriptPromise: Promise<void> | null = null;
|
||||||
let configured = false;
|
let configured = false;
|
||||||
let configuredUrl: string | null = null;
|
let configuredUrl: string | null = null;
|
||||||
|
|
||||||
function currentPageUrl() {
|
|
||||||
return typeof window !== 'undefined' ? normalizeJssdkPageUrl(window.location.href) : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 参与 JSSDK 签名的页面 URL(去掉 hash、OAuth 回跳参数) */
|
/** 参与 JSSDK 签名的页面 URL(去掉 hash、OAuth 回跳参数) */
|
||||||
export function normalizeJssdkPageUrl(rawUrl: string): string {
|
export function normalizeJssdkPageUrl(rawUrl: string): string {
|
||||||
try {
|
try {
|
||||||
@@ -25,6 +23,33 @@ export function normalizeJssdkPageUrl(rawUrl: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** iOS 微信内 SPA:签名 URL 须用首次进入页面的入口 URL,而非路由跳转后的当前 URL */
|
||||||
|
export function captureIosJssdkEntryUrl(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
if (!isIosDevice() || !isWechatBrowser() || isWechatDevTools()) return;
|
||||||
|
if (sessionStorage.getItem(IOS_ENTRY_URL_KEY)) return;
|
||||||
|
sessionStorage.setItem(IOS_ENTRY_URL_KEY, normalizeJssdkPageUrl(window.location.href));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取参与 JSSDK 签名的 URL */
|
||||||
|
export function getJssdkSignUrl(rawUrl?: string): string {
|
||||||
|
const current = normalizeJssdkPageUrl(rawUrl ?? (typeof window !== 'undefined' ? window.location.href : ''));
|
||||||
|
if (typeof window !== 'undefined' && isIosDevice() && isWechatBrowser() && !isWechatDevTools()) {
|
||||||
|
const entry = sessionStorage.getItem(IOS_ENTRY_URL_KEY);
|
||||||
|
if (entry) return entry;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isJssdkDebugEnabled(): boolean {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
try {
|
||||||
|
return new URLSearchParams(window.location.search).get('wxdebug') === '1';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function stripOAuthParamsFromLocation(): void {
|
export function stripOAuthParamsFromLocation(): void {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
@@ -77,8 +102,9 @@ export async function initWechatJssdk(options: {
|
|||||||
getAccessToken?: () => string | null;
|
getAccessToken?: () => string | null;
|
||||||
jsApiList?: string[];
|
jsApiList?: string[];
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
|
captureIosJssdkEntryUrl();
|
||||||
const { apiBase, clientApp, getAccessToken } = options;
|
const { apiBase, clientApp, getAccessToken } = options;
|
||||||
const pageUrl = options.url ?? currentPageUrl();
|
const pageUrl = options.url ?? getJssdkSignUrl();
|
||||||
await loadScript();
|
await loadScript();
|
||||||
if (!window.wx) throw new Error('微信 JSSDK 不可用');
|
if (!window.wx) throw new Error('微信 JSSDK 不可用');
|
||||||
|
|
||||||
@@ -92,7 +118,7 @@ export async function initWechatJssdk(options: {
|
|||||||
window.wx!.config({
|
window.wx!.config({
|
||||||
...config,
|
...config,
|
||||||
jsApiList,
|
jsApiList,
|
||||||
debug: false,
|
debug: isJssdkDebugEnabled(),
|
||||||
});
|
});
|
||||||
window.wx!.ready(() => {
|
window.wx!.ready(() => {
|
||||||
configured = true;
|
configured = true;
|
||||||
@@ -104,7 +130,8 @@ export async function initWechatJssdk(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isJssdkReady(): boolean {
|
export function isJssdkReady(): boolean {
|
||||||
return configured && !!window.wx && configuredUrl === currentPageUrl();
|
const signUrl = getJssdkSignUrl();
|
||||||
|
return configured && !!window.wx && configuredUrl === signUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureJssdkReady(options: {
|
export async function ensureJssdkReady(options: {
|
||||||
@@ -114,8 +141,13 @@ export async function ensureJssdkReady(options: {
|
|||||||
getAccessToken?: () => string | null;
|
getAccessToken?: () => string | null;
|
||||||
jsApiList?: string[];
|
jsApiList?: string[];
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const pageUrl = options.url ?? currentPageUrl();
|
captureIosJssdkEntryUrl();
|
||||||
|
const pageUrl = options.url ?? getJssdkSignUrl();
|
||||||
if (!isJssdkReady()) {
|
if (!isJssdkReady()) {
|
||||||
await initWechatJssdk({ ...options, url: pageUrl });
|
await initWechatJssdk({ ...options, url: pageUrl });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
captureIosJssdkEntryUrl();
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,57 @@
|
|||||||
import { getRuntimePlatform } from './env';
|
import { getRuntimePlatform, isIosDevice, isWechatDevTools } from './env';
|
||||||
import { ensureJssdkReady } from './jssdk';
|
import { ensureJssdkReady } from './jssdk';
|
||||||
import type { WeixinSdkConfig } from './types';
|
import type { WeixinSdkConfig } from './types';
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将微信 scanQRCode fail 的 errMsg 转为用户可读文案(避免误判为系统相机权限) */
|
||||||
|
export function formatScanFailMessage(errMsg: string): string {
|
||||||
|
const msg = errMsg.trim() || '扫码失败';
|
||||||
|
if (/cancel/i.test(msg)) return '';
|
||||||
|
|
||||||
|
// iOS 常见:JSSDK 权限离线校验尚未完成,并非系统相机权限
|
||||||
|
if (/offline verifying|permission value is offline/i.test(msg)) {
|
||||||
|
return '微信权限验证中,请稍候再试或刷新页面后重新扫码';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/invalid signature|config:fail|signature/i.test(msg)) {
|
||||||
|
return '微信 JSSDK 签名校验失败,请刷新页面后重试';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 系统级相机权限(iPhone 设置 → 隐私 → 相机 → 微信)
|
||||||
|
if (/system.*camera|NotAllowedError|camera.*not.*allowed|无法访问相机|无相机权限/i.test(msg)) {
|
||||||
|
return '相机权限未开启,请在 iPhone「设置 → 隐私与安全性 → 相机」中允许微信使用摄像头';
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSSDK 接口授权失败(含 permission,但不等于相机权限)
|
||||||
|
if (/permission|auth|denied|授权|拒绝/i.test(msg)) {
|
||||||
|
return `微信扫码接口未就绪(${msg}),请刷新页面后重试`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function invokeScanQrCode(): Promise<string | null> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
window.wx!.scanQRCode!({
|
||||||
|
needResult: 1,
|
||||||
|
scanType: ['qrCode', 'barCode'],
|
||||||
|
success: (res) => resolve(res.resultStr || null),
|
||||||
|
fail: (res) => {
|
||||||
|
const msg = res.errMsg || '扫码失败';
|
||||||
|
if (/cancel/i.test(msg)) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const formatted = formatScanFailMessage(msg);
|
||||||
|
reject(new Error(formatted || msg));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** 调起扫码(返回二维码/条码内容,失败返回 null) */
|
/** 调起扫码(返回二维码/条码内容,失败返回 null) */
|
||||||
export async function scanQrCode(config: WeixinSdkConfig): Promise<string | null> {
|
export async function scanQrCode(config: WeixinSdkConfig): Promise<string | null> {
|
||||||
const platform = getRuntimePlatform();
|
const platform = getRuntimePlatform();
|
||||||
@@ -26,25 +76,29 @@ export async function scanQrCode(config: WeixinSdkConfig): Promise<string | null
|
|||||||
if (!window.wx?.scanQRCode) {
|
if (!window.wx?.scanQRCode) {
|
||||||
throw new Error('当前微信版本不支持扫码,请升级微信后重试');
|
throw new Error('当前微信版本不支持扫码,请升级微信后重试');
|
||||||
}
|
}
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
window.wx!.scanQRCode!({
|
// iOS 在 wx.ready 后立即调 scanQRCode 可能触发 offline verifying
|
||||||
needResult: 1,
|
if (isIosDevice() && !isWechatDevTools()) {
|
||||||
scanType: ['qrCode', 'barCode'],
|
await delay(500);
|
||||||
success: (res) => resolve(res.resultStr || null),
|
|
||||||
fail: (res) => {
|
|
||||||
const msg = res.errMsg || '扫码失败';
|
|
||||||
if (/cancel/i.test(msg)) {
|
|
||||||
resolve(null);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (/permission|auth|denied|授权|拒绝|camera/i.test(msg)) {
|
|
||||||
reject(new Error('相机权限未开启,请在微信设置中允许使用摄像头'));
|
const maxAttempts = 2;
|
||||||
return;
|
let lastError: Error | null = null;
|
||||||
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
|
if (attempt > 0) {
|
||||||
|
await delay(800);
|
||||||
}
|
}
|
||||||
reject(new Error(msg));
|
try {
|
||||||
},
|
return await invokeScanQrCode();
|
||||||
});
|
} catch (e) {
|
||||||
});
|
lastError = e instanceof Error ? e : new Error('扫码失败');
|
||||||
|
const raw = lastError.message;
|
||||||
|
const retryable = /offline verifying|权限验证中|接口未就绪/i.test(raw);
|
||||||
|
if (attempt < maxAttempts - 1 && retryable) continue;
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError ?? new Error('扫码失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error('请在微信内打开以使用扫码核销');
|
throw new Error('请在微信内打开以使用扫码核销');
|
||||||
|
|||||||
Reference in New Issue
Block a user