// Serial QR requests: a refresh cannot replace a scan while its confirmation poll is still in flight. export const SCAN_POLL_MS = 2_000; export const SCAN_REFRESH_MS = 10_000; export const SCAN_CONFIRM_REFRESH_MS = 60_000; export function createQrLogin({ request, encode, onQr, onPending, onError, onConfirmed, onBusy, now = Date.now, timers = globalThis, isHidden = () => false }) { let stopped = false, completed = false, confirmed = false, active = false, busy = false, timer; let ready = false, awaiting = false, manual = true; let refreshAt = Infinity, pollAt = Infinity; const clear = () => { timers.clearTimeout(timer); timer = undefined; }; const schedule = () => { clear(); if (stopped || completed || !active || busy) return; const due = manual ? now() : ready ? Math.min(refreshAt, pollAt) : refreshAt; timer = timers.setTimeout(tick, isHidden() ? SCAN_POLL_MS : Math.max(0, due - now())); }; const generate = async () => { manual = false; ready = false; awaiting = false; // Keep the displayed image until its replacement is encoded; refreshing only changes the existing img's src. onBusy(true); onPending(false); onError(null); try { const result = await request('scan/start', {}); if (stopped) return; if (!result.ok || !result.scanUrl) throw new Error(result.error || 'scan_response'); const image = await encode(result.scanUrl); if (stopped) return; ready = true; refreshAt = now() + SCAN_REFRESH_MS; pollAt = now() + SCAN_POLL_MS; onQr(image); } finally { if (!stopped) onBusy(false); } }; async function tick() { clear(); if (stopped || completed || !active || busy) return; if (isHidden()) { schedule(); return; } busy = true; try { if (confirmed) { manual = false; await onConfirmed(); completed = true; } else if (manual || !ready) await generate(); else { // Poll once before an automatic refresh too: a just-scanned QR gets its full confirmation window. const result = await request('scan/poll', {}); if (stopped) return; if (!result.ok || result.error) throw new Error(result.error || 'scan_response'); if (result.state === 'confirmed') { confirmed = true; onQr(null); await onConfirmed(); completed = true; return; } if (result.state !== 'waiting') throw new Error('scan_expired'); if (result.awaitingConfirmation && !awaiting) { awaiting = true; refreshAt = now() + SCAN_CONFIRM_REFRESH_MS; onPending(true); } pollAt = now() + SCAN_POLL_MS; if (active && !isHidden() && (manual || now() >= refreshAt)) await generate(); } } catch (error) { if (!stopped) { ready = false; refreshAt = now() + SCAN_REFRESH_MS; awaiting = false; onPending(false); onQr(null); onError(error); } } finally { busy = false; schedule(); } } return { setActive(value) { active = value; schedule(); }, refresh() { manual = true; schedule(); }, stop() { stopped = true; clear(); }, }; }