import { useEffect, useRef } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { apiFetch, isPremium } from '../lib/api'; import { useHeadlessScan } from './useHeadlessScan'; const STATUS_POLL_MS = 5 * 60 * 1000; // catches a scan becoming due while the dashboard stays open type ScheduledScanStatus = { status: 'idle' | 'due' | 'running' | 'completed' | 'unavailable'; cursor?: number; total?: number; }; type ClaimResponse = | { claimed: true; token: string; urls: string[]; cursor: number } | { claimed: false; status: string }; /** * WP-Cron can't run axe-core (no DOM/JS in a PHP request) — it only flips a * scan to "due" (see AccessMate_Pro::mark_scheduled_scan_due()). This hook is * the other half: while an authorized admin has the AccessMate dashboard * open, it claims a due scan and drives it through the exact same * useHeadlessScan → runAutoScan() → /axe-scan pipeline PageScanner's Bulk * Scan already uses, so Scheduled/Bulk/Individual all produce identical * results. No-ops entirely for Free (isPremium false). * * A claim is a server-side compare-and-swap (see try_claim_scheduled_scan()), * so multiple tabs/admins/refreshes can't double-run it — only one wins. * Progress is reported after every URL so a closed tab's claim expires and * the next session resumes from where it left off, rather than restarting. */ export function useScheduledScanRunner(): void { const qc = useQueryClient(); const { scanUrlAsync } = useHeadlessScan(); const runningRef = useRef(false); const { data: status } = useQuery({ queryKey: ['scheduled-scan-status'], queryFn: () => apiFetch('/scheduled-scan/status'), enabled: isPremium, refetchInterval: STATUS_POLL_MS, staleTime: 0, }); useEffect(() => { if (!isPremium || runningRef.current) return; if (status?.status !== 'due' && status?.status !== 'running') return; runningRef.current = true; (async () => { try { const claim = await apiFetch('/scheduled-scan/claim', { method: 'POST' }); if (!claim.claimed) return; // another session already owns it, or nothing to do for (let i = claim.cursor; i < claim.urls.length; i++) { await scanUrlAsync(claim.urls[i]).catch(() => {}); // a broken page shouldn't block the rest of the batch const progress = await apiFetch<{ ok: boolean }>('/scheduled-scan/progress', { method: 'POST', body: JSON.stringify({ token: claim.token, cursor: i + 1 }), }).catch(() => ({ ok: false })); if (!progress.ok) return; // lease lost to another session — stop, don't fight over it } await apiFetch('/scheduled-scan/complete', { method: 'POST', body: JSON.stringify({ token: claim.token }), }).catch(() => undefined); qc.invalidateQueries({ queryKey: ['page-scans'] }); qc.invalidateQueries({ queryKey: ['report'] }); qc.invalidateQueries({ queryKey: ['scheduled-scan-status'] }); } finally { runningRef.current = false; } })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [status?.status]); }