import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { GroupTextCracker, type ProgressReport } from 'meshcore-hashtag-cracker'; import NoSleep from 'nosleep.js'; import type { RawPacket, Channel } from '../types'; import { api } from '../api'; import { toast } from './ui/sonner'; import { cn } from '@/lib/utils'; import { extractPacketPayloadHex } from '../utils/pathUtils'; interface CrackedChannel { channelName: string; key: string; packetId: number; message: string; crackedAt: number; } interface QueueItem { packet: RawPacket; attempts: number; lastAttemptLength: number; status: 'pending' | 'cracking' | 'cracked' | 'failed'; } export interface CrackerPanelProps { packets: RawPacket[]; channels: Channel[]; onChannelCreate: (name: string, key: string) => Promise; onRunningChange?: (running: boolean) => void; visible?: boolean; } export function CrackerPanel({ packets, channels, onChannelCreate, onRunningChange, visible = false, }: CrackerPanelProps) { const [isRunning, setIsRunning] = useState(false); const [maxLength, setMaxLength] = useState(6); const [maxLengthInput, setMaxLengthInput] = useState('6'); const [retryFailedAtNextLength, setRetryFailedAtNextLength] = useState(false); const [decryptHistorical, setDecryptHistorical] = useState(true); const [turboMode, setTurboMode] = useState(false); const [twoWordMode, setTwoWordMode] = useState(false); const [progress, setProgress] = useState(null); const [queue, setQueue] = useState>(new Map()); const [crackedChannels, setCrackedChannels] = useState([]); const [wordlistLoaded, setWordlistLoaded] = useState(false); const [gpuAvailable, setGpuAvailable] = useState(null); const [undecryptedPacketCount, setUndecryptedPacketCount] = useState(null); const [skippedDuplicates, setSkippedDuplicates] = useState(0); const crackerRef = useRef(null); const noSleepRef = useRef(null); const isRunningRef = useRef(false); const abortedRef = useRef(false); const isProcessingRef = useRef(false); const queueRef = useRef>(new Map()); const retryFailedRef = useRef(false); const maxLengthRef = useRef(6); const decryptHistoricalRef = useRef(true); const turboModeRef = useRef(false); const twoWordModeRef = useRef(false); const undecryptedIdsRef = useRef>(new Set()); const seenPayloadsRef = useRef>(new Set()); const existingChannelKeysRef = useRef>(new Set()); // Initialize cracker and NoSleep useEffect(() => { const cracker = new GroupTextCracker(); crackerRef.current = cracker; setGpuAvailable(cracker.isGpuAvailable()); const noSleep = new NoSleep(); noSleepRef.current = noSleep; return () => { cracker.destroy(); crackerRef.current = null; noSleep.disable(); noSleepRef.current = null; }; }, []); // Load wordlist dynamically when panel becomes visible for the first time useEffect(() => { if (!visible || wordlistLoaded) return; import('meshcore-hashtag-cracker/wordlist') .then(({ ENGLISH_WORDLIST }) => { if (crackerRef.current) { crackerRef.current.setWordlist(ENGLISH_WORDLIST); setWordlistLoaded(true); } }) .catch((err) => { console.error('Failed to load wordlist:', err); toast.error('Failed to load wordlist', { description: 'Channel finder will not be available', }); }); }, [visible, wordlistLoaded]); // Fetch undecrypted packet count useEffect(() => { const fetchCount = () => { api .getUndecryptedPacketCount() .then(({ count }) => setUndecryptedPacketCount(count)) .catch(() => setUndecryptedPacketCount(null)); }; fetchCount(); // Refresh hourly (this is just for display; not critical to be up-to-date) const interval = setInterval(fetchCount, 3600000); return () => clearInterval(interval); }, []); // Get existing channel keys for filtering (memoized to avoid recreating on every render) const existingChannelKeys = useMemo( () => new Set(channels.map((c) => c.key.toUpperCase())), [channels] ); useEffect(() => { existingChannelKeysRef.current = existingChannelKeys; }, [existingChannelKeys]); // Filter packets to only undecrypted GROUP_TEXT const undecryptedGroupText = useMemo( () => packets.filter((p) => p.payload_type === 'GROUP_TEXT' && !p.decrypted), [packets] ); // Update queue when packets change (deduplicated by payload) // Note: We intentionally depend on .length only to avoid re-running on every array identity change useEffect(() => { let newSkipped = 0; setQueue((prev) => { const newQueue = new Map(prev); let changed = false; for (const packet of undecryptedGroupText) { if (!newQueue.has(packet.id)) { // Extract payload and check for duplicates const payload = extractPacketPayloadHex(packet.data); if (payload && seenPayloadsRef.current.has(payload)) { // Skip - we already have a packet with this payload queued newSkipped++; continue; } // Track this payload as seen if (payload) { seenPayloadsRef.current.add(payload); } newQueue.set(packet.id, { packet, attempts: 0, lastAttemptLength: 0, status: 'pending', }); changed = true; } } if (changed) { queueRef.current = newQueue; return newQueue; } return prev; }); if (newSkipped > 0) { setSkippedDuplicates((prev) => prev + newSkipped); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [undecryptedGroupText.length]); // Keep refs in sync with state useEffect(() => { queueRef.current = queue; }, [queue]); useEffect(() => { retryFailedRef.current = retryFailedAtNextLength; }, [retryFailedAtNextLength]); useEffect(() => { maxLengthRef.current = maxLength; }, [maxLength]); useEffect(() => { setMaxLengthInput(String(maxLength)); }, [maxLength]); useEffect(() => { decryptHistoricalRef.current = decryptHistorical; }, [decryptHistorical]); useEffect(() => { turboModeRef.current = turboMode; }, [turboMode]); useEffect(() => { twoWordModeRef.current = twoWordMode; }, [twoWordMode]); // Keep undecrypted IDs ref in sync - used to skip packets already decrypted by other means useEffect(() => { undecryptedIdsRef.current = new Set(undecryptedGroupText.map((p) => p.id)); }, [undecryptedGroupText]); // Notify parent of running state changes useEffect(() => { onRunningChange?.(isRunning); }, [isRunning, onRunningChange]); // Stats (cracking count is implicit - if progress is shown, we're cracking one) const pendingCount = Array.from(queue.values()).filter((q) => q.status === 'pending').length; const crackedCount = Array.from(queue.values()).filter((q) => q.status === 'cracked').length; const failedCount = Array.from(queue.values()).filter((q) => q.status === 'failed').length; // Process next packet in queue const processNext = useCallback(async () => { // Prevent concurrent processing if (isProcessingRef.current) return; if (!crackerRef.current || !isRunningRef.current) return; const currentQueue = queueRef.current; // Find next pending packet let nextItem: QueueItem | null = null; let nextId: number | null = null; for (const [id, item] of currentQueue.entries()) { if (item.status === 'pending') { nextItem = item; nextId = id; break; } } // If no pending and retry option is enabled, pick the failed one with lowest lastAttemptLength if (!nextItem && retryFailedRef.current) { const failedItems = Array.from(currentQueue.entries()).filter( ([, item]) => item.status === 'failed' && item.lastAttemptLength < 10 // Hard cap at length 10 ); if (failedItems.length > 0) { // Sort by lastAttemptLength ascending and pick the first (lowest) failedItems.sort((a, b) => a[1].lastAttemptLength - b[1].lastAttemptLength); [nextId, nextItem] = failedItems[0]; } } if (!nextItem || nextId === null) { // Nothing to process right now, but keep running and check again later if (isRunningRef.current) { setTimeout(() => processNext(), 1000); } return; } // Check if this packet is still undecrypted - it may have been decrypted // by historical decrypt when we cracked another packet from the same channel if (!undecryptedIdsRef.current.has(nextId)) { // Already decrypted by other means, remove from queue and continue setQueue((prev) => { const updated = new Map(prev); updated.delete(nextId); return updated; }); if (isRunningRef.current) { setTimeout(() => processNext(), 10); } return; } // Lock processing isProcessingRef.current = true; const currentMaxLength = maxLengthRef.current; const isRetry = nextItem.lastAttemptLength > 0; const targetLength = isRetry ? nextItem.lastAttemptLength + 1 : currentMaxLength; try { const result = await crackerRef.current.crack( nextItem.packet.data, { maxLength: targetLength, useSenderFilter: true, useTimestampFilter: true, useUtf8Filter: true, useTwoWordCombinations: twoWordModeRef.current, ...(turboModeRef.current && { gpuDispatchMs: 10000 }), // For retries, skip dictionary and shorter lengths - we already checked those ...(isRetry && { useDictionary: false, useTwoWordCombinations: false, startingLength: targetLength, }), }, (prog) => { setProgress(prog); } ); if (abortedRef.current) { abortedRef.current = false; isProcessingRef.current = false; setProgress(null); return; } if (result.found && result.roomName && result.key) { // Success! setQueue((prev) => { const updated = new Map(prev); const item = updated.get(nextId!); if (item) { updated.set(nextId!, { ...item, status: 'cracked', attempts: item.attempts + 1, lastAttemptLength: targetLength, }); } return updated; }); const newCracked: CrackedChannel = { channelName: result.roomName, key: result.key, packetId: nextId!, message: result.decryptedMessage || '', crackedAt: Date.now(), }; setCrackedChannels((prev) => [...prev, newCracked]); // Auto-add channel if not already exists const keyUpper = result.key.toUpperCase(); if (!existingChannelKeysRef.current.has(keyUpper)) { try { const channelName = '#' + result.roomName; await onChannelCreate(channelName, result.key); // Optionally decrypt any other historical packets with this newly discovered key // This prevents wasting cracking cycles on packets from the same channel if (decryptHistoricalRef.current) { await api.decryptHistoricalPackets({ key_type: 'channel', channel_name: channelName, }); } } catch (err) { console.error('Failed to create channel or decrypt historical:', err); toast.error('Failed to save found channel', { description: err instanceof Error ? err.message : 'Channel discovered but could not be saved', }); } } } else { // Failed setQueue((prev) => { const updated = new Map(prev); const item = updated.get(nextId!); if (item) { updated.set(nextId!, { ...item, status: 'failed', attempts: item.attempts + 1, lastAttemptLength: targetLength, }); } return updated; }); } } catch (err) { console.error('Cracking error:', err); setQueue((prev) => { const updated = new Map(prev); const item = updated.get(nextId!); if (item) { updated.set(nextId!, { ...item, status: 'failed', attempts: item.attempts + 1, lastAttemptLength: targetLength, }); } return updated; }); } // Unlock processing isProcessingRef.current = false; setProgress(null); // Continue processing if still running if (isRunningRef.current) { setTimeout(() => processNext(), 100); } }, [onChannelCreate]); // Start/stop handlers const handleStart = () => { if (!gpuAvailable) { toast.error('WebGPU not available', { description: typeof window !== 'undefined' && !window.isSecureContext ? 'WebGPU requires HTTPS when not on localhost. Set up a certificate or configure your browser to treat this origin as secure.' : 'Channel finder requires Chrome 113+ or Edge 113+ with WebGPU support.', }); return; } setIsRunning(true); isRunningRef.current = true; abortedRef.current = false; noSleepRef.current?.enable(); processNext(); }; const handleStop = () => { setIsRunning(false); isRunningRef.current = false; abortedRef.current = true; crackerRef.current?.abort(); noSleepRef.current?.disable(); }; return (
{ const nextValue = e.target.value; setMaxLengthInput(nextValue); if (nextValue === '') return; const parsed = Number.parseInt(nextValue, 10); if (Number.isNaN(parsed)) return; setMaxLength(Math.min(10, Math.max(1, parsed))); }} onBlur={() => { const parsed = Number.parseInt(maxLengthInput, 10); const nextValue = Number.isNaN(parsed) ? maxLength : Math.min(10, Math.max(1, parsed)); setMaxLengthInput(String(nextValue)); if (nextValue !== maxLength) { setMaxLength(nextValue); } }} className="w-14 px-2 py-1 text-sm bg-muted border border-border rounded" />
{decryptHistorical && ( {undecryptedPacketCount !== null && undecryptedPacketCount > 0 ? `(${undecryptedPacketCount.toLocaleString()} packets; messages will stream in as decrypted)` : '(messages will stream in as decrypted)'} )}
{/* Status */}
Pending: {pendingCount} Found: {crackedCount} Failed: {failedCount} {skippedDuplicates > 0 && ( Skipped (dup):{' '} {skippedDuplicates} )}
{/* Progress */} {progress && (
{progress.phase === 'wordlist' ? 'Dictionary' : progress.phase === 'wordlist-pairs' ? 'Word Pairs' : progress.phase === 'bruteforce' ? 'Bruteforce' : 'Public Key'} {progress.phase === 'bruteforce' && ` - Length ${progress.currentLength}`}:{' '} {progress.currentPosition} {progress.rateKeysPerSec >= 1e9 ? `${(progress.rateKeysPerSec / 1e9).toFixed(2)} Gkeys/s` : `${(progress.rateKeysPerSec / 1e6).toFixed(1)} Mkeys/s`}{' '} • ETA:{' '} {progress.etaSeconds < 60 ? `${Math.round(progress.etaSeconds)}s` : `${Math.round(progress.etaSeconds / 60)}m`}
)} {/* GPU status */} {gpuAvailable === false && (

WebGPU not available.

{typeof window !== 'undefined' && !window.isSecureContext ? (

WebGPU requires HTTPS when not on localhost.

To enable it:

  • Set up a TLS certificate (see the HTTPS section of README_ADVANCED.md, or re-run the Docker setup script which can generate one automatically)
  • Or configure your browser to treat this origin as secure (sometimes called “insecure origins treated as secure” in browser flags)
) : (

Channel finder requires Chrome 113+ or Edge 113+ with WebGPU support.

)}
)} {!wordlistLoaded && gpuAvailable !== false && (
Loading wordlist...
)} {/* Found channels list */} {crackedChannels.length > 0 && (
Found Channels:
{crackedChannels.map((channel, i) => (
#{channel.channelName} "{channel.message.slice(0, 50)} {channel.message.length > 50 ? '...' : ''}"
))}
)}

For unknown-keyed GroupText packets, this will attempt to dictionary attack, then brute force payloads as they arrive, testing channel names up to the specified length to discover active channels on the local mesh (GroupText packets may not be hashtag messages; we have no way of knowing but try as if they are). Retry failed at n+1 will return to the failed queue and pick up messages it couldn't find a key for, attempting them at one longer length. Try word pairs will also try every combination of two dictionary words concatenated together (e.g. "hello" + "world" = "#helloworld") after the single-word dictionary pass; this can substantially increase search time and also result in false-positives. Decrypt historical will run an async job on any channel name it finds to see if any historically captured packets will decrypt with that key. Turbo mode will push your GPU to the max (target dispatch time of 10s) and may allow accelerated searching and/or system instability.

); }