CI / test (ubuntu-latest, node 22) (push) Failing after 33m10s
CI / test (ubuntu-latest, node 24) (push) Failing after 34m41s
CI / tooling (lint, imports, typecheck) (push) Failing after 36m11s
CI / test (windows-latest, node 22) (push) Canceled after 0s
CI / test (windows-latest, node 24) (push) Canceled after 0s
71 lines
3.0 KiB
JavaScript
71 lines
3.0 KiB
JavaScript
// 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(); },
|
|
};
|
|
}
|