diff --git a/client/package.json b/client/package.json index 4ad1d40..56f6f6b 100644 --- a/client/package.json +++ b/client/package.json @@ -48,6 +48,16 @@ ] }, "devDependencies": { + "@types/babel__generator": "^7.27.0", + "@types/babel__template": "^7.4.4", + "@types/babel__traverse": "^7.28.0", + "@types/d3-color": "^3.1.3", + "@types/d3-path": "^3.1.1", + "@types/eslint": "^9.6.1", + "@types/estree": "^1.0.8", + "@types/istanbul-lib-report": "^3.0.3", + "@types/yargs-parser": "^21.0.3", + "ajv": "^8.17.1", "autoprefixer": "^10.4.22", "postcss": "^8.5.6", "tailwindcss": "^3.4.17" @@ -60,4 +70,3 @@ } } } - diff --git a/client/src/App.tsx b/client/src/App.tsx index e53ccb9..66a62f1 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -2,6 +2,8 @@ import React, { useEffect, useState } from 'react'; import { io, Socket } from 'socket.io-client'; import { Login } from './components/Login'; import { Dashboard } from './components/Dashboard'; +import { Language, getTranslation } from './i18n'; +import { Globe, Sun, Moon } from 'lucide-react'; // Create socket with autoConnect disabled so we can add listeners before connecting const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:3001'; @@ -20,6 +22,16 @@ export interface ConnectionState { function App() { const [isConnected, setIsConnected] = useState(socket.connected); + const [language, setLanguage] = useState(() => { + const saved = localStorage.getItem('language'); + return (saved as Language) || 'en'; + }); + const [langButtonAnimating, setLangButtonAnimating] = useState(false); + const [theme, setTheme] = useState<'light' | 'dark'>(() => { + const saved = localStorage.getItem('theme'); + return (saved as 'light' | 'dark') || 'light'; + }); + const [themeButtonAnimating, setThemeButtonAnimating] = useState(false); const [connectionState, setConnectionState] = useState({ whatsapp: false, signal: false, @@ -108,32 +120,152 @@ function App() { }; }, []); + const handleLanguageChange = (lang: Language) => { + setLangButtonAnimating(true); + setLanguage(lang); + localStorage.setItem('language', lang); + + // Reset animation after 500ms + setTimeout(() => { + setLangButtonAnimating(false); + }, 500); + }; + + const handleThemeChange = () => { + setThemeButtonAnimating(true); + const newTheme = theme === 'light' ? 'dark' : 'light'; + setTheme(newTheme); + localStorage.setItem('theme', newTheme); + + // Reset animation after 500ms + setTimeout(() => { + setThemeButtonAnimating(false); + }, 500); + }; + return ( -
+
-
-

Activity Tracker

-
-
- {isConnected ? 'Server Connected' : 'Disconnected'} - {isConnected && ( - <> -
-
- WhatsApp -
-
- Signal - - )} +
+

{getTranslation(language, 'title')}

+
+ {/* Theme Toggle */} + + + {/* Language Switcher */} +
+ {/* Animated Background Slider */} +
+ + + +
+ +
+
+ {isConnected ? getTranslation(language, 'serverConnected') : getTranslation(language, 'disconnected')} + {isConnected && ( + <> +
+
+ WhatsApp +
+
+ Signal + + )} +
{!isAnyPlatformReady ? ( - + ) : ( - + )}
diff --git a/client/src/components/ContactCard.tsx b/client/src/components/ContactCard.tsx index f03ec98..7a6f0d4 100644 --- a/client/src/components/ContactCard.tsx +++ b/client/src/components/ContactCard.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; import { Square, Activity, Wifi, Smartphone, Monitor, MessageCircle } from 'lucide-react'; import clsx from 'clsx'; +import { Language, getTranslation } from '../i18n'; type Platform = 'whatsapp' | 'signal'; @@ -29,9 +30,14 @@ interface ContactCardProps { deviceCount: number; presence: string | null; profilePic: string | null; + isTracking: boolean; + onPause: () => void; + onResume: () => void; onRemove: () => void; privacyMode?: boolean; platform?: Platform; + language: Language; + theme: 'light' | 'dark'; } export function ContactCard({ @@ -42,9 +48,14 @@ export function ContactCard({ deviceCount, presence, profilePic, + isTracking, + onPause, + onResume, onRemove, privacyMode = false, - platform = 'whatsapp' + platform = 'whatsapp', + language, + theme }: ContactCardProps) { const lastData = data[data.length - 1]; const currentStatus = devices.length > 0 @@ -57,9 +68,15 @@ export function ContactCard({ const blurredNumber = privacyMode ? displayNumber.replace(/\d/g, '•') : displayNumber; return ( -
+
{/* Header with Stop Button */} -
+
{platform === 'whatsapp' ? 'WhatsApp' : 'Signal'} -

{blurredNumber}

+

{blurredNumber}

+
+
+ {isTracking ? ( + + ) : ( + + )} +
-
{/* Status Card */} -
+
-
+
{profilePic ? ( ) : ( -
- No Image +
+ {getTranslation(language, 'profilePic')}
)}
-

{blurredNumber}

+

{blurredNumber}

{currentStatus}
-
-
- Official Status - {presence || 'Unknown'} +
+
+ {getTranslation(language, 'officialStatus')} + + {presence + ? presence === 'available' + ? getTranslation(language, 'available') + : presence === 'unavailable' + ? getTranslation(language, 'unavailable') + : presence + : getTranslation(language, 'unknown')} +
-
- Devices +
+ {getTranslation(language, 'devices')} {deviceCount || 0}
{/* Device List */} {devices.length > 0 && ( -
-
Device States
+
+
{getTranslation(language, 'deviceStates')}
{devices.map((device, idx) => (
- - Device {idx + 1} + + {getTranslation(language, 'device')} {idx + 1}
{device.state} @@ -163,34 +231,67 @@ export function ContactCard({
{/* Metrics Grid */}
-
-
Current Avg RTT
-
{lastData?.avg.toFixed(0) || '-'} ms
+
+
{getTranslation(language, 'currentAvgRTT')}
+
{lastData?.avg.toFixed(0) || '-'} ms
-
-
Median (50)
-
{lastData?.median.toFixed(0) || '-'} ms
+
+
{getTranslation(language, 'median')}
+
{lastData?.median.toFixed(0) || '-'} ms
-
-
Threshold
-
{lastData?.threshold.toFixed(0) || '-'} ms
+
+
{getTranslation(language, 'threshold')}
+
{lastData?.threshold.toFixed(0) || '-'} ms
{/* Chart */} -
-
RTT History & Threshold
+
+
{getTranslation(language, 'rttHistory')}
- - - + + + new Date(t).toLocaleTimeString()} - contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }} + contentStyle={{ + borderRadius: '8px', + border: 'none', + boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)', + backgroundColor: theme === 'dark' ? '#1f2937' : '#ffffff', + color: theme === 'dark' ? '#f3f4f6' : '#000000' + }} /> - - + +
diff --git a/client/src/components/ContactsList.tsx b/client/src/components/ContactsList.tsx new file mode 100644 index 0000000..162c1f0 --- /dev/null +++ b/client/src/components/ContactsList.tsx @@ -0,0 +1,309 @@ +import React, { useState, useMemo } from 'react'; +import { Pause, Play, Trash2, MessageCircle, ChevronDown, Search } from 'lucide-react'; +import { Language, getTranslation } from '../i18n'; +import clsx from 'clsx'; + +interface Contact { + jid: string; + displayNumber: string; + isTracking: boolean; + platform: 'whatsapp' | 'signal'; + state?: string; +} + +interface ContactsListProps { + contacts: Contact[]; + onPause: (jid: string) => void; + onResume: (jid: string) => void; + onRemove: (jid: string) => void; + language: Language; + theme: 'light' | 'dark'; +} + +type SortBy = 'name' | 'platform' | 'status'; +type FilterBy = 'all' | 'active' | 'paused' | 'whatsapp' | 'signal'; + +export function ContactsList({ contacts, onPause, onResume, onRemove, language, theme }: ContactsListProps) { + const [searchQuery, setSearchQuery] = useState(''); + const [sortBy, setSortBy] = useState('name'); + const [filterBy, setFilterBy] = useState('all'); + + // Filter и Sort контакты + const filteredAndSorted = useMemo(() => { + let result = [...contacts]; + + // Filter по поиску + if (searchQuery.trim()) { + result = result.filter(c => + c.displayNumber.toLowerCase().includes(searchQuery.toLowerCase()) + ); + } + + // Filter по статусу и платформе + result = result.filter(c => { + if (filterBy === 'active') return c.isTracking; + if (filterBy === 'paused') return !c.isTracking; + if (filterBy === 'whatsapp') return c.platform === 'whatsapp'; + if (filterBy === 'signal') return c.platform === 'signal'; + return true; + }); + + // Sort + result.sort((a, b) => { + switch (sortBy) { + case 'platform': + return a.platform.localeCompare(b.platform); + case 'status': + return (b.isTracking ? 1 : 0) - (a.isTracking ? 1 : 0); + case 'name': + default: + return a.displayNumber.localeCompare(b.displayNumber); + } + }); + + return result; + }, [contacts, searchQuery, sortBy, filterBy]); + + const stats = { + total: contacts.length, + active: contacts.filter(c => c.isTracking).length, + paused: contacts.filter(c => !c.isTracking).length, + whatsapp: contacts.filter(c => c.platform === 'whatsapp').length, + signal: contacts.filter(c => c.platform === 'signal').length, + }; + + if (contacts.length === 0) { + return ( +
+ {getTranslation(language, 'noContactsTracked')} +
+ ); + } + + return ( +
+ {/* Header with Stats */} +
+
+
+

+ {getTranslation(language, 'trackContacts')} +

+
+ 📊 {getTranslation(language, 'stop')}: {stats.total} + 🟢 {getTranslation(language, 'start')}: {stats.active} + ⏸️ {getTranslation(language, 'paused')}: {stats.paused} + 📱 WhatsApp: {stats.whatsapp} + 🔔 Signal: {stats.signal} +
+
+
+
+ + {/* Search, Filter, Sort */} +
+ {/* Search */} +
+ + setSearchQuery(e.target.value)} + className={`w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm ${ + theme === 'dark' + ? 'bg-gray-700 border-gray-600 text-white placeholder-gray-500' + : 'border-gray-300 bg-white text-gray-900' + }`} + /> +
+ + {/* Filter и Sort Controls */} +
+ + + +
+ + {searchQuery && ( +

+ Найдено: {filteredAndSorted.length} из {contacts.length} +

+ )} +
+ + {/* Contacts List */} +
+ {filteredAndSorted.length > 0 ? ( + filteredAndSorted.map((contact) => ( +
+ {/* Contact Info */} +
+
+ + + + {contact.platform === 'whatsapp' ? 'WhatsApp' : 'Signal'} + + +
+

+ {contact.displayNumber} +

+ {contact.state && ( +

+ {contact.state} +

+ )} +
+
+ + {/* Status Badge */} + + {contact.isTracking ? '🟢 Активен' : '⏸️ На паузе'} + + + {/* Actions */} +
+ {contact.isTracking ? ( + + ) : ( + + )} + + +
+
+ )) + ) : ( +
+ Контакты не найдены +
+ )} +
+
+ ); +} diff --git a/client/src/components/Dashboard.tsx b/client/src/components/Dashboard.tsx index 2c8da9c..a712262 100644 --- a/client/src/components/Dashboard.tsx +++ b/client/src/components/Dashboard.tsx @@ -1,13 +1,17 @@ import React, { useEffect, useState } from 'react'; -import {Eye, EyeOff, Plus, Trash2, Zap, MessageCircle, Settings} from 'lucide-react'; +import { Eye, EyeOff, Plus, Trash2, Zap, MessageCircle, Settings } from 'lucide-react'; import { socket, Platform, ConnectionState } from '../App'; import { ContactCard } from './ContactCard'; +import { ContactsList } from './ContactsList'; import { Login } from './Login'; +import { Language, getTranslation } from '../i18n'; type ProbeMethod = 'delete' | 'reaction'; interface DashboardProps { connectionState: ConnectionState; + language: Language; + theme: 'light' | 'dark'; } interface TrackerData { @@ -36,9 +40,10 @@ interface ContactInfo { presence: string | null; profilePic: string | null; platform: Platform; + isTracking: boolean; } -export function Dashboard({ connectionState }: DashboardProps) { +export function Dashboard({ connectionState, language, theme }: DashboardProps) { const [inputNumber, setInputNumber] = useState(''); const [selectedPlatform, setSelectedPlatform] = useState( connectionState.whatsapp ? 'whatsapp' : 'signal' @@ -48,6 +53,9 @@ export function Dashboard({ connectionState }: DashboardProps) { const [privacyMode, setPrivacyMode] = useState(false); const [probeMethod, setProbeMethod] = useState('delete'); const [showConnections, setShowConnections] = useState(false); + const [minDelay, setMinDelay] = useState(500); + const [maxDelay, setMaxDelay] = useState(1000); + const [delayApplying, setDelayApplying] = useState(false); useEffect(() => { function onTrackerUpdate(update: any) { @@ -117,22 +125,46 @@ export function Dashboard({ connectionState }: DashboardProps) { function onContactAdded(data: { jid: string, number: string, platform?: Platform }) { setContacts(prev => { const next = new Map(prev); + const existingContact = next.get(data.jid); next.set(data.jid, { jid: data.jid, displayNumber: data.number, - contactName: data.number, - data: [], - devices: [], - deviceCount: 0, - presence: null, - profilePic: null, - platform: data.platform || 'whatsapp' + contactName: existingContact?.contactName || data.number, + data: existingContact?.data || [], + devices: existingContact?.devices || [], + deviceCount: existingContact?.deviceCount || 0, + presence: existingContact?.presence || null, + profilePic: existingContact?.profilePic || null, + platform: data.platform || 'whatsapp', + isTracking: true }); return next; }); setInputNumber(''); } + function onTrackingPaused(jid: string) { + setContacts(prev => { + const next = new Map(prev); + const contact = next.get(jid); + if (contact) { + next.set(jid, { ...contact, isTracking: false }); + } + return next; + }); + } + + function onTrackingResumed(jid: string) { + setContacts(prev => { + const next = new Map(prev); + const contact = next.get(jid); + if (contact) { + next.set(jid, { ...contact, isTracking: true }); + } + return next; + }); + } + function onContactRemoved(jid: string) { setContacts(prev => { const next = new Map(prev); @@ -172,7 +204,8 @@ export function Dashboard({ connectionState }: DashboardProps) { deviceCount: 0, presence: null, profilePic: null, - platform + platform, + isTracking: true }); } }); @@ -184,6 +217,8 @@ export function Dashboard({ connectionState }: DashboardProps) { socket.on('profile-pic', onProfilePic); socket.on('contact-name', onContactName); socket.on('contact-added', onContactAdded); + socket.on('tracking-paused', onTrackingPaused); + socket.on('tracking-resumed', onTrackingResumed); socket.on('contact-removed', onContactRemoved); socket.on('error', onError); socket.on('probe-method', onProbeMethod); @@ -197,6 +232,8 @@ export function Dashboard({ connectionState }: DashboardProps) { socket.off('profile-pic', onProfilePic); socket.off('contact-name', onContactName); socket.off('contact-added', onContactAdded); + socket.off('tracking-paused', onTrackingPaused); + socket.off('tracking-resumed', onTrackingResumed); socket.off('contact-removed', onContactRemoved); socket.off('error', onError); socket.off('probe-method', onProbeMethod); @@ -209,7 +246,15 @@ export function Dashboard({ connectionState }: DashboardProps) { socket.emit('add-contact', { number: inputNumber, platform: selectedPlatform }); }; - const handleRemove = (jid: string) => { + const handlePauseTracking = (jid: string) => { + socket.emit('pause-tracking', jid); + }; + + const handleResumeTracking = (jid: string) => { + socket.emit('resume-tracking', jid); + }; + + const handleRemoveContact = (jid: string) => { socket.emit('remove-contact', jid); }; @@ -217,128 +262,246 @@ export function Dashboard({ connectionState }: DashboardProps) { socket.emit('set-probe-method', method); }; + const handleDelayChange = () => { + if (minDelay >= maxDelay) { + setError('Min delay must be less than max delay'); + return; + } + setDelayApplying(true); + socket.emit('set-probe-delay', { minDelay, maxDelay }); + + // Visual feedback: reset button state after 1.5 seconds + setTimeout(() => { + setDelayApplying(false); + }, 1500); + }; + return (
+ {/* Contacts List Management */} + {contacts.size > 0 && ( + ({ + jid: c.jid, + displayNumber: c.contactName, + isTracking: c.isTracking, + platform: c.platform, + state: c.devices?.[0]?.state + }))} + onPause={handlePauseTracking} + onResume={handleResumeTracking} + onRemove={handleRemoveContact} + language={language} + theme={theme} + /> + )} + {/* Add Contact Form */} -
-
-
-

Track Contacts

+
+
+
+

{getTranslation(language, 'trackContacts')}

{/* Manage Connections button */}
-
+
+ {/* Probe Delay Settings */} +
+ + setMinDelay(parseInt(e.target.value))} + className={`w-16 px-2 py-1 text-sm border rounded focus:ring-2 focus:ring-blue-500 outline-none ${theme === 'dark' + ? 'bg-gray-600 border-gray-500 text-white' + : 'border-gray-300' + }`} + title={getTranslation(language, 'minDelay')} + /> + - + setMaxDelay(parseInt(e.target.value))} + className={`w-16 px-2 py-1 text-sm border rounded focus:ring-2 focus:ring-blue-500 outline-none ${theme === 'dark' + ? 'bg-gray-600 border-gray-500 text-white' + : 'border-gray-300' + }`} + title={getTranslation(language, 'maxDelay')} + /> + +
+ {/* Probe Method Toggle */}
- Probe Method: -
+ {getTranslation(language, 'probeMethod')} +
{/* Privacy Mode Toggle */} - +
{/* Platform Selector */} -
+
setInputNumber(e.target.value)} onKeyPress={(e) => e.key === 'Enter' && handleAdd()} />
{error &&

{error}

} @@ -346,14 +509,19 @@ export function Dashboard({ connectionState }: DashboardProps) { {/* Connections Panel */} {showConnections && ( - + )} {/* Contact Cards */} {contacts.size === 0 ? ( -
-

No contacts being tracked

-

Add a contact above to start tracking

+
+

{getTranslation(language, 'noContactsTracked')}

+

{getTranslation(language, 'addContactToStart')}

) : (
@@ -367,9 +535,14 @@ export function Dashboard({ connectionState }: DashboardProps) { deviceCount={contact.deviceCount} presence={contact.presence} profilePic={contact.profilePic} - onRemove={() => handleRemove(contact.jid)} + isTracking={contact.isTracking} + onPause={() => handlePauseTracking(contact.jid)} + onResume={() => handleResumeTracking(contact.jid)} + onRemove={() => handleRemoveContact(contact.jid)} privacyMode={privacyMode} platform={contact.platform} + language={language} + theme={theme} /> ))}
diff --git a/client/src/components/Login.tsx b/client/src/components/Login.tsx index 0420406..1256539 100644 --- a/client/src/components/Login.tsx +++ b/client/src/components/Login.tsx @@ -2,85 +2,118 @@ import React from 'react'; import { QRCodeSVG } from 'qrcode.react'; import { ConnectionState } from '../App'; import { CheckCircle } from 'lucide-react'; +import { Language, getTranslation } from '../i18n'; interface LoginProps { connectionState: ConnectionState; + language: Language; + theme: 'light' | 'dark'; } -export function Login({ connectionState }: LoginProps) { +export function Login({ connectionState, language, theme }: LoginProps) { return (
{/* WhatsApp Connection */} -
+
-

Connect WhatsApp

+

{getTranslation(language, 'connectWhatsApp')}

{connectionState.whatsapp && ( )}
{connectionState.whatsapp ? ( -
+
- Connected! + {getTranslation(language, 'connected')}
) : ( <> -
+
{connectionState.whatsappQr ? ( ) : ( -
- Waiting for QR Code... +
+ {getTranslation(language, 'waitingForQR')}
)}
-

- Open WhatsApp on your phone, go to Settings {'>'} Linked Devices, and scan the QR code to connect. +

+ {getTranslation(language, 'scanQRWhatsApp')}

)}
{/* Signal Connection */} -
+
-

Connect Signal

+

{getTranslation(language, 'connectSignal')}

{connectionState.signal && ( )}
{connectionState.signal ? ( -
+
- Connected! - {connectionState.signalNumber} + {getTranslation(language, 'connected')} + {connectionState.signalNumber}
) : connectionState.signalApiAvailable ? ( <> -
+
{connectionState.signalQrImage ? ( Signal QR Code ) : ( -
- Waiting for QR Code... +
+ {getTranslation(language, 'waitingForQR')}
)}
-

- Open Signal on your phone, go to Settings {'>'} Linked Devices, and scan the QR code to connect. +

+ {getTranslation(language, 'scanQRSignal')}

) : ( -
-

Signal API not available

-

Run the signal-cli-rest-api Docker container to enable

+
+

{getTranslation(language, 'signalAPINotAvailable')}

+

{getTranslation(language, 'signalAPINote')}

)}
diff --git a/client/src/i18n.ts b/client/src/i18n.ts new file mode 100644 index 0000000..6a6d06f --- /dev/null +++ b/client/src/i18n.ts @@ -0,0 +1,118 @@ +export type Language = 'en' | 'ru'; + +export const translations = { + en: { + title: 'Activity Tracker', + connectWhatsApp: 'Connect WhatsApp', + connectSignal: 'Connect Signal', + connected: 'Connected!', + waitingForQR: 'Waiting for QR Code...', + scanQRWhatsApp: 'Open WhatsApp on your phone, go to Settings > Linked Devices, and scan the QR code to connect.', + scanQRSignal: 'Open Signal on your phone, go to Settings > Linked Devices, and scan the QR code to connect.', + signalAPINotAvailable: 'Signal API not available', + signalAPINote: 'Run the signal-cli-rest-api Docker container to enable', + serverConnected: 'Server Connected', + disconnected: 'Disconnected', + trackContacts: 'Track Contacts', + manageConnections: 'Manage Connections', + hideConnections: 'Hide Connections', + probeMethod: 'Probe Method:', + delete: 'Delete', + reaction: 'Reaction', + privacyON: 'Privacy ON', + privacyOFF: 'Privacy OFF', + enterPhoneNumber: 'Enter phone number', + addContact: 'Add Contact', + noContactsTracked: 'No contacts being tracked', + addContactToStart: 'Add a contact above to start tracking', + stop: 'Stop', + start: 'Start', + paused: 'Paused', + remove: 'Remove', + officialStatus: 'Official Status', + devices: 'Devices', + deviceStates: 'Device States', + device: 'Device', + currentAvgRTT: 'Current Avg RTT', + median: 'Median (50)', + threshold: 'Threshold', + rttHistory: 'RTT History & Threshold', + profilePic: 'No Image', + unknown: 'Unknown', + online: 'Online', + standby: 'Standby', + offline: 'Offline', + calibrating: 'Calibrating...', + language: 'Language', + deleteProbeTitle: 'Silent Delete Probe - Completely covert, target sees nothing', + reactionProbeTitle: 'Reaction Probe - Sends reactions to non-existent messages', + privacyModeOn: 'Privacy Mode: ON (Click to disable)', + privacyModeOff: 'Privacy Mode: OFF (Click to enable)', + whatsappNotConnected: 'WhatsApp not connected', + signalNotConnected: 'Signal not connected', + probeDelay: 'Probe Delay (ms)', + minDelay: 'Min', + maxDelay: 'Max', + available: 'Available', + unavailable: 'Unavailable', + }, + ru: { + title: 'Трекер Активности', + connectWhatsApp: 'Подключить WhatsApp', + connectSignal: 'Подключить Signal', + connected: 'Подключено!', + waitingForQR: 'Ожидание QR кода...', + scanQRWhatsApp: 'Откройте WhatsApp на телефоне, перейдите в Параметры > Связанные устройства и отсканируйте QR код.', + scanQRSignal: 'Откройте Signal на телефоне, перейдите в Параметры > Связанные устройства и отсканируйте QR код.', + signalAPINotAvailable: 'Signal API недоступен', + signalAPINote: 'Запустите Docker контейнер signal-cli-rest-api для включения', + serverConnected: 'Сервер подключен', + disconnected: 'Отключено', + trackContacts: 'Отслеживать контакты', + manageConnections: 'Управление подключениями', + hideConnections: 'Скрыть подключения', + probeMethod: 'Метод зонда:', + delete: 'Удалить', + reaction: 'Реакция', + privacyON: 'Приватность ВКЛ', + privacyOFF: 'Приватность ВЫКЛ', + enterPhoneNumber: 'Введите номер телефона', + addContact: 'Добавить контакт', + noContactsTracked: 'Нет отслеживаемых контактов', + addContactToStart: 'Добавьте контакт выше, чтобы начать отслеживание', + stop: 'Остановить', + start: 'Запустить', + paused: 'Приостановлено', + remove: 'Удалить', + officialStatus: 'Официальный статус', + devices: 'Устройства', + deviceStates: 'Состояние устройств', + device: 'Устройство', + currentAvgRTT: 'Текущее среднее RTT', + median: 'Медиана (50)', + threshold: 'Порог', + rttHistory: 'История RTT и порог', + profilePic: 'Нет изображения', + unknown: 'Неизвестно', + online: 'Онлайн', + standby: 'В режиме ожидания', + offline: 'Оффлайн', + calibrating: 'Калибровка...', + language: 'Язык', + deleteProbeTitle: 'Скрытый зонд удаления - полностью скрыт, цель ничего не видит', + reactionProbeTitle: 'Зонд реакции - отправляет реакции на несуществующие сообщения', + privacyModeOn: 'Приватность: ВКЛ (нажмите для отключения)', + privacyModeOff: 'Приватность: ВЫКЛ (нажмите для включения)', + whatsappNotConnected: 'WhatsApp не подключен', + signalNotConnected: 'Signal не подключен', + probeDelay: 'Интервал зондирования (мс)', + minDelay: 'Мин', + maxDelay: 'Макс', + available: 'Доступен', + unavailable: 'Недоступно', + }, +}; + +export function getTranslation(lang: Language, key: keyof typeof translations.en): string { + return translations[lang][key] || translations.en[key]; +} diff --git a/client/src/index.css b/client/src/index.css index 5976139..f315bdd 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -2,6 +2,36 @@ @tailwind components; @tailwind utilities; +/* Global Button Style */ +@layer components { + .btn-primary { + @apply px-4 py-2 rounded-lg font-semibold transition-all duration-300 border-2; + } + + .btn-primary-light { + @apply bg-gradient-to-br from-blue-500 to-indigo-600 text-white border-blue-400 hover:border-blue-300; + } + + .btn-primary-dark { + @apply bg-gradient-to-br from-indigo-600 to-blue-700 text-white border-indigo-500 hover:border-indigo-400; + } + + /* Input/Select Style */ + input[type="text"], + input[type="number"], + select, + textarea { + @apply transition-all duration-300 focus:outline-none; + } + + input[type="text"]:focus, + input[type="number"]:focus, + select:focus, + textarea:focus { + @apply ring-2 ring-blue-500 ring-opacity-50; + } +} + body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', diff --git a/src/server.ts b/src/server.ts index 2fa8b51..d47e9f5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -32,11 +32,13 @@ const io = new Server(httpServer, { }); let sock: any; -let isWhatsAppConnected = false; -let isSignalConnected = false; -let signalAccountNumber: string | null = null; -let globalProbeMethod: ProbeMethod = 'delete'; // Default to delete method -let currentWhatsAppQr: string | null = null; // Store current QR code for new clients + let isWhatsAppConnected = false; + let isSignalConnected = false; + let signalAccountNumber: string | null = null; + let globalProbeMethod: ProbeMethod = 'delete'; // Default to delete method + let currentWhatsAppQr: string | null = null; // Store current QR code for new clients + let globalProbeMinDelay: number = 500; // Min delay in ms + let globalProbeMaxDelay: number = 1000; // Max delay in ms // Platform type for contacts type Platform = 'whatsapp' | 'signal'; @@ -49,41 +51,41 @@ interface TrackerEntry { const trackers: Map = new Map(); // JID/Number -> Tracker entry async function connectToWhatsApp() { - const { state, saveCreds } = await useMultiFileAuthState('auth_info_baileys'); - - sock = makeWASocket({ - auth: state, - logger: pino({ level: 'debug' }), - markOnlineOnConnect: true, - printQRInTerminal: false, - }); - - sock.ev.on('connection.update', async (update: any) => { - const { connection, lastDisconnect, qr } = update; - - if (qr) { - console.log('QR Code generated'); - currentWhatsAppQr = qr; // Store the QR code - io.emit('qr', qr); - } - - if (connection === 'close') { - isWhatsAppConnected = false; - currentWhatsAppQr = null; // Clear QR on close - const shouldReconnect = (lastDisconnect?.error as Boom)?.output?.statusCode !== DisconnectReason.loggedOut; - console.log('connection closed, reconnecting ', shouldReconnect); - if (shouldReconnect) { - connectToWhatsApp(); - } - } else if (connection === 'open') { - isWhatsAppConnected = true; - currentWhatsAppQr = null; // Clear QR on successful connection - console.log('opened connection'); - io.emit('connection-open'); - } - }); - - sock.ev.on('creds.update', saveCreds); + const { state, saveCreds } = await useMultiFileAuthState('auth_info_baileys'); + + sock = makeWASocket({ + auth: state, + logger: pino({ level: 'error' }), // Changed from 'debug' to 'error' to reduce noise + markOnlineOnConnect: true, + printQRInTerminal: false, + }); + + sock.ev.on('connection.update', async (update: any) => { + const { connection, lastDisconnect, qr } = update; + + if (qr) { + console.log('QR Code generated'); + currentWhatsAppQr = qr; // Store the QR code + io.emit('qr', qr); + } + + if (connection === 'close') { + isWhatsAppConnected = false; + currentWhatsAppQr = null; // Clear QR on close + const shouldReconnect = (lastDisconnect?.error as Boom)?.output?.statusCode !== DisconnectReason.loggedOut; + console.log('connection closed, reconnecting ', shouldReconnect); + if (shouldReconnect) { + setTimeout(() => connectToWhatsApp(), 3000); // Reconnect after 3 seconds + } + } else if (connection === 'open') { + isWhatsAppConnected = true; + currentWhatsAppQr = null; // Clear QR on successful connection + console.log('opened connection'); + io.emit('connection-open'); + } + }); + + sock.ev.on('creds.update', saveCreds); sock.ev.on('messaging-history.set', ({ chats, contacts, messages, isLatest }: any) => { console.log(`[SESSION] History sync - Chats: ${chats.length}, Contacts: ${contacts.length}, Messages: ${messages.length}, Latest: ${isLatest}`); @@ -391,13 +393,31 @@ io.on('connection', (socket) => { } }); + socket.on('pause-tracking', (jid: string) => { + console.log(`Request to pause tracking: ${jid}`); + const entry = trackers.get(jid); + if (entry) { + entry.tracker.stopTracking(); + io.emit('tracking-paused', jid); + } + }); + + socket.on('resume-tracking', (jid: string) => { + console.log(`Request to resume tracking: ${jid}`); + const entry = trackers.get(jid); + if (entry) { + entry.tracker.startTracking(); + io.emit('tracking-resumed', jid); + } + }); + socket.on('remove-contact', (jid: string) => { - console.log(`Request to stop tracking: ${jid}`); + console.log(`Request to remove contact: ${jid}`); const entry = trackers.get(jid); if (entry) { entry.tracker.stopTracking(); trackers.delete(jid); - socket.emit('contact-removed', jid); + io.emit('contact-removed', jid); } }); @@ -421,6 +441,35 @@ io.on('connection', (socket) => { io.emit('probe-method', method); console.log(`Probe method changed to: ${method}`); }); + + socket.on('set-probe-delay', (data: { minDelay: number; maxDelay: number }) => { + const timestamp = new Date().toLocaleTimeString(); + console.log(`\n[${timestamp}] Probe delay change requested`); + console.log(` Min: ${data.minDelay}ms, Max: ${data.maxDelay}ms`); + + if (data.minDelay < 10 || data.maxDelay > 5000 || data.minDelay >= data.maxDelay) { + console.log(` REJECTED - Invalid values (Min must be >= 10, Max must be <= 5000, Min < Max)`); + socket.emit('error', { message: 'Invalid delay values' }); + return; + } + + globalProbeMinDelay = data.minDelay; + globalProbeMaxDelay = data.maxDelay; + + let updatedTrackers = 0; + for (const entry of trackers.values()) { + if (entry.platform === 'whatsapp') { + (entry.tracker as WhatsAppTracker).setProbeDelay(data.minDelay, data.maxDelay); + } else { + (entry.tracker as SignalTracker).setProbeDelay(data.minDelay, data.maxDelay); + } + updatedTrackers++; + } + + io.emit('probe-delay-updated', { minDelay: data.minDelay, maxDelay: data.maxDelay }); + console.log(` ACCEPTED - Updated ${updatedTrackers} active tracker(s)`); + console.log(` Range: ${data.minDelay}-${data.maxDelay}ms (~${Math.round(1000 / ((data.minDelay + data.maxDelay) / 2))} probes/sec)\n`); + }); }); const PORT = parseInt(process.env.PORT || '3001', 10); diff --git a/src/signal-tracker.ts b/src/signal-tracker.ts index 9b857c5..a7eaae9 100644 --- a/src/signal-tracker.ts +++ b/src/signal-tracker.ts @@ -61,16 +61,18 @@ class TrackerLogger { const logger = new TrackerLogger(true); export class SignalTracker { - private apiUrl: string; - private senderNumber: string; - private targetNumber: string; - private isTracking: boolean = false; - private deviceMetrics: Map = new Map(); - private globalRttHistory: number[] = []; - private probeMethod: ProbeMethod = 'reaction'; - private ws: WebSocket | null = null; - private reconnectTimeout: NodeJS.Timeout | null = null; - public onUpdate?: (data: any) => void; + private apiUrl: string; + private senderNumber: string; + private targetNumber: string; + private isTracking: boolean = false; + private deviceMetrics: Map = new Map(); + private globalRttHistory: number[] = []; + private probeMethod: ProbeMethod = 'reaction'; + private probeMinDelay: number = 1000; // Min delay in ms + private probeMaxDelay: number = 2000; // Max delay in ms + private ws: WebSocket | null = null; + private reconnectTimeout: NodeJS.Timeout | null = null; + public onUpdate?: (data: any) => void; // Serialized probe tracking (per Codex recommendation) // Only ONE probe in flight at a time - correlate by order, not timestamp @@ -99,6 +101,12 @@ export class SignalTracker { return this.probeMethod; } + public setProbeDelay(minDelay: number, maxDelay: number) { + this.probeMinDelay = minDelay; + this.probeMaxDelay = maxDelay; + logger.info(`Probe delay changed to: ${minDelay}-${maxDelay}ms`); + } + /** * Start tracking the target user's activity */ @@ -230,8 +238,9 @@ export class SignalTracker { } catch (err) { logger.debug('Error sending probe:', err); } - // Small delay between probes - const delay = Math.floor(Math.random() * 1000) + 1000; + // Delay between probes + const range = this.probeMaxDelay - this.probeMinDelay; + const delay = Math.floor(Math.random() * range) + this.probeMinDelay; await new Promise(resolve => setTimeout(resolve, delay)); } } diff --git a/src/tracker.ts b/src/tracker.ts index d765d46..e6cbe2b 100644 --- a/src/tracker.ts +++ b/src/tracker.ts @@ -94,17 +94,19 @@ interface DeviceMetrics { * by Gegenhuber et al., University of Vienna & SBA Research */ export class WhatsAppTracker { - private sock: WASocket; - private targetJid: string; - private trackedJids: Set = new Set(); // Multi-device support - private isTracking: boolean = false; - private deviceMetrics: Map = new Map(); - private globalRttHistory: number[] = []; // For threshold calculation - private probeStartTimes: Map = new Map(); - private probeTimeouts: Map = new Map(); - private lastPresence: string | null = null; - private probeMethod: ProbeMethod = 'delete'; // Default to delete method - public onUpdate?: (data: any) => void; + private sock: WASocket; + private targetJid: string; + private trackedJids: Set = new Set(); // Multi-device support + private isTracking: boolean = false; + private deviceMetrics: Map = new Map(); + private globalRttHistory: number[] = []; // For threshold calculation + private probeStartTimes: Map = new Map(); + private probeTimeouts: Map = new Map(); + private lastPresence: string | null = null; + private probeMethod: ProbeMethod = 'delete'; // Default to delete method + private probeMinDelay: number = 500; // Min delay in ms + private probeMaxDelay: number = 1000; // Max delay in ms + public onUpdate?: (data: any) => void; constructor(sock: WASocket, targetJid: string, debugMode: boolean = false) { this.sock = sock; @@ -122,6 +124,12 @@ export class WhatsAppTracker { return this.probeMethod; } + public setProbeDelay(minDelay: number, maxDelay: number) { + this.probeMinDelay = minDelay; + this.probeMaxDelay = maxDelay; + trackerLogger.info(`\n🔄 Probe delay changed to: ${minDelay}-${maxDelay}ms\n`); + } + /** * Start tracking the target user's activity * Sets up event listeners for message receipts and presence updates @@ -192,12 +200,13 @@ export class WhatsAppTracker { private async probeLoop() { while (this.isTracking) { + const range = this.probeMaxDelay - this.probeMinDelay; + const delay = Math.floor(Math.random() * range) + this.probeMinDelay; try { await this.sendProbe(); } catch (err) { logger.error(err, 'Error sending probe'); } - const delay = Math.floor(Math.random() * 100) + 2000; await new Promise(resolve => setTimeout(resolve, delay)); } } @@ -241,20 +250,22 @@ export class WhatsAppTracker { trackerLogger.debug(`[PROBE-DELETE] Delete probe sent successfully, message ID: ${result.key.id}`); this.probeStartTimes.set(result.key.id, startTime); - // Set timeout: if no CLIENT ACK within 10 seconds, mark device as OFFLINE + // Set timeout: if no CLIENT ACK within timeout period, mark device as OFFLINE + // Timeout = max delay * 10 (adaptive to probe interval) + const timeoutMs = this.probeMaxDelay * 10; const timeoutId = setTimeout(() => { - if (this.probeStartTimes.has(result.key.id!)) { - const elapsedTime = Date.now() - startTime; - trackerLogger.debug(`[PROBE-DELETE TIMEOUT] No CLIENT ACK for ${result.key.id} after ${elapsedTime}ms - Device is OFFLINE`); - this.probeStartTimes.delete(result.key.id!); - this.probeTimeouts.delete(result.key.id!); + if (this.probeStartTimes.has(result.key.id!)) { + const elapsedTime = Date.now() - startTime; + trackerLogger.debug(`[PROBE-DELETE TIMEOUT] No CLIENT ACK for ${result.key.id} after ${elapsedTime}ms - Device is OFFLINE`); + this.probeStartTimes.delete(result.key.id!); + this.probeTimeouts.delete(result.key.id!); // Mark device as OFFLINE due to no response if (result.key.remoteJid) { this.markDeviceOffline(result.key.remoteJid, elapsedTime); } - } - }, 10000); // 10 seconds timeout + } + }, timeoutMs); this.probeTimeouts.set(result.key.id, timeoutId); } else { @@ -297,23 +308,25 @@ export class WhatsAppTracker { const startTime = Date.now(); if (result?.key?.id) { - trackerLogger.debug(`[PROBE-REACTION] Probe sent successfully, message ID: ${result.key.id}`); - this.probeStartTimes.set(result.key.id, startTime); - - // Set timeout: if no CLIENT ACK within 10 seconds, mark device as OFFLINE - const timeoutId = setTimeout(() => { - if (this.probeStartTimes.has(result.key.id!)) { - const elapsedTime = Date.now() - startTime; - trackerLogger.debug(`[PROBE-REACTION TIMEOUT] No CLIENT ACK for ${result.key.id} after ${elapsedTime}ms - Device is OFFLINE`); - this.probeStartTimes.delete(result.key.id!); - this.probeTimeouts.delete(result.key.id!); - - // Mark device as OFFLINE due to no response - if (result.key.remoteJid) { - this.markDeviceOffline(result.key.remoteJid, elapsedTime); - } - } - }, 10000); // 10 seconds timeout + trackerLogger.debug(`[PROBE-REACTION] Probe sent successfully, message ID: ${result.key.id}`); + this.probeStartTimes.set(result.key.id, startTime); + + // Set timeout: if no CLIENT ACK within timeout period, mark device as OFFLINE + // Timeout = max delay * 10 (adaptive to probe interval) + const timeoutMs = this.probeMaxDelay * 10; + const timeoutId = setTimeout(() => { + if (this.probeStartTimes.has(result.key.id!)) { + const elapsedTime = Date.now() - startTime; + trackerLogger.debug(`[PROBE-REACTION TIMEOUT] No CLIENT ACK for ${result.key.id} after ${elapsedTime}ms - Device is OFFLINE`); + this.probeStartTimes.delete(result.key.id!); + this.probeTimeouts.delete(result.key.id!); + + // Mark device as OFFLINE due to no response + if (result.key.remoteJid) { + this.markDeviceOffline(result.key.remoteJid, elapsedTime); + } + } + }, timeoutMs); this.probeTimeouts.set(result.key.id, timeoutId); } else {