fix bug of WebOTP

This commit is contained in:
2026-07-21 12:06:42 +02:00
parent a60f1fbc68
commit 3be79ef9f0
6 changed files with 429 additions and 171 deletions
+93 -16
View File
@@ -1,39 +1,116 @@
import { persianToEnglishDigits } from '@/utils/persianDigits'
/** استخراج کد عددی از نتیجه WebOTP / Credential */
type OtpHandler = (code: string) => void
let activeController: AbortController | null = null
let listenGeneration = 0
let pendingCode: string | null = null
const handlers = new Set<OtpHandler>()
/** استخراج کد عددی از نتیجه WebOTP / Credential / رشته خام */
export function extractOtpCode(
credential: Credential | null | undefined,
length = 4
credential: Credential | null | undefined | string,
length = 4,
): string {
const raw = (credential as { code?: string } | null | undefined)?.code ?? ''
let raw = ''
if (typeof credential === 'string') {
raw = credential
} else if (credential && typeof credential === 'object') {
const c = credential as { code?: string; id?: string }
raw = c.code ?? c.id ?? ''
}
return persianToEnglishDigits(raw).replace(/\D/g, '').slice(0, length)
}
/**
* گوش دادن به SMS از طریق WebOTP API (Chrome Android).
* با کلیک Allow روی دیالوگ مرورگر، onCode با کد ۴ رقمی صدا زده می‌شود.
*/
export function listenForSmsOtp(onCode: (code: string) => void): AbortController {
const ac = new AbortController()
function emitCode(code: string) {
pendingCode = code
handlers.forEach((handler) => {
try {
handler(code)
} catch {
/* ignore subscriber errors */
}
})
}
if (!('OTPCredential' in window) || !navigator.credentials?.get) {
return ac
/**
* شروع زودهنگام WebOTP — باید قبل از ارسال SMS / همزمان با درخواست کد صدا زده شود.
* اگر از قبل در حال listen باشد، دوباره abort نمی‌کند (دیالوگ Allow را خراب نکند).
*/
export function beginSmsOtpCapture(length = 4): void {
if (typeof window === 'undefined' || !navigator.credentials?.get) {
return
}
if (activeController && !activeController.signal.aborted) {
return
}
const ac = new AbortController()
activeController = ac
const generation = ++listenGeneration
void navigator.credentials
.get({
otp: { transport: ['sms'] },
signal: ac.signal,
} as CredentialRequestOptions)
.then((credential) => {
const code = extractOtpCode(credential, 4)
if (code.length === 4) {
onCode(code)
if (generation !== listenGeneration) return
const code = extractOtpCode(credential, length)
if (code.length === length) {
emitCode(code)
}
})
.catch(() => {
/* aborted or denied — ignore */
/* Deny / Abort / unsupported */
})
.finally(() => {
if (activeController === ac) {
activeController = null
}
})
}
/** عضویت برای دریافت کد — اگر کد از قبل آمده باشد همان لحظه می‌دهد */
export function subscribeSmsOtp(handler: OtpHandler): () => void {
handlers.add(handler)
if (pendingCode) {
try {
handler(pendingCode)
} catch {
/* ignore */
}
}
return () => {
handlers.delete(handler)
}
}
export function clearPendingSmsOtp(): void {
pendingCode = null
}
export function stopSmsOtpCapture(): void {
listenGeneration += 1
activeController?.abort()
activeController = null
}
/**
* سازگاری با کد قبلی — subscribe + begin
* توجه: abort کردن این controller دیگر listener سراسری را قطع نمی‌کند.
*/
export function listenForSmsOtp(onCode: (code: string) => void): AbortController {
const ac = new AbortController()
const unsubscribe = subscribeSmsOtp((code) => {
if (!ac.signal.aborted) {
onCode(code)
}
})
beginSmsOtpCapture()
ac.signal.addEventListener('abort', () => {
unsubscribe()
})
return ac
}