'use strict'; const $ = (id) => document.getElementById(id); let statusTimer = null; let lastData = null; let knownPeersKey = ''; let rangeInitialized = false; let pendingTrustId = null; function esc(v) { return String(v).replace(/[&<>"']/g, (s) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[s])); } function showStatus(msg, isError = false) { const el = $('status'); clearTimeout(statusTimer); el.style.display = 'block'; el.style.background = isError ? 'rgba(239,68,68,.2)' : 'rgba(16,185,129,.2)'; el.style.border = `1px solid ${isError ? '#ef4444' : '#10b981'}`; el.textContent = msg; statusTimer = setTimeout(() => { el.style.display = 'none'; }, 4500); } function formatBytes(b) { if (b < 1024) return `${b} Б`; if (b < 1024 ** 2) return `${(b / 1024).toFixed(1)} КБ`; if (b < 1024 ** 3) return `${(b / 1024 ** 2).toFixed(2)} МБ`; return `${(b / 1024 ** 3).toFixed(2)} ГБ`; } // Обновляем DOM только если разметка реально изменилась (не сбрасывает скролл/hover/картинки) function setHtml(el, html) { if (el._h !== html) { el._h = html; el.innerHTML = html; } } async function api(path, opts = {}) { const res = await fetch(path, { credentials: 'same-origin', ...opts }); if (!res.ok) throw new Error((await res.text()) || `Ошибка ${res.status}`); return res; } function postJson(path, body) { return api(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); } // ───────────── рендер ───────────── function renderHeader(d) { $('my-name').textContent = `Устройство: ${d.device_name}`; $('my-ip').textContent = `Наш адрес: ${d.my_ip}:${d.p2p_port} | Отпечаток: ${d.my_fingerprint.slice(0, 16)}…`; $('my-pin').textContent = d.my_pin; $('media-dir').textContent = d.media_dir; if (!rangeInitialized && d.my_ip && d.my_ip.includes('.') && !d.my_ip.startsWith('127.')) { const p = d.my_ip.split('.'); $('scan-range-input').value = `${p[0]}.${p[1]}.${p[2]}.1-254`; rangeInitialized = true; } } function renderScan(scan) { const box = $('scan-progress-box'); if (scan && (scan.active || scan.total > 0)) { box.style.display = 'block'; const pct = scan.total > 0 ? Math.round((scan.scanned / scan.total) * 100) : 0; $('scan-label').textContent = scan.active ? `Сканирование ${scan.range}… (найдено: ${scan.found})` : `Сканирование ${scan.range} завершено (найдено: ${scan.found})`; $('scan-numbers').textContent = `${scan.scanned} / ${scan.total} (${pct}%)`; $('scan-bar-fill').style.width = `${pct}%`; $('btn-scan-range').disabled = scan.active; $('btn-scan-all').disabled = scan.active; } } function renderTransfers(list) { setHtml($('transfers-list'), (list || []).map((t) => { const dir = t.direction === 'out' ? '⬆️ Отправка на' : '⬇️ Приём от'; const status = t.error ? '✗ Ошибка' : t.done ? '✓ Завершено' : `${t.percent}%`; const note = t.error ? `${esc(t.error)}` : (t.done ? 'Готово' : 'Передача по TLS…'); return `
${dir} ${esc(t.peer_name)}: ${esc(t.filename)} ${esc(status)}
${formatBytes(t.transferred_bytes)} из ${formatBytes(t.total_bytes)} ${note}
`; }).join('')); } function renderPeers(peers) { const box = $('peers-list'); if (!peers.length) { setHtml(box, '
Устройства пока не найдены. Нажмите «Вся подсеть» или введите IP вручную.
'); } else { setHtml(box, peers.map((p) => `
${esc(p.name)} (${esc(p.ips.join(', ') || 'адрес неизвестен')}:${esc(p.port)})
Код сверки: ${esc(p.pin_code)}
Статус: ${p.verified ? 'Заверен' : 'Не заверен'}
`).join('')); } const key = peers.map((p) => `${p.id}:${p.verified}:${p.ips[0] || ''}:${p.name}`).join('|'); if (key !== knownPeersKey) { knownPeersKey = key; const sel = $('target-peer'); const prev = sel.value; sel.innerHTML = '' + peers.map((p) => ``).join(''); if (prev) sel.value = prev; } } function renderTexts(texts) { const box = $('ram-texts'); if (!texts.length) return setHtml(box, '
Буфер ОЗУ пуст
'); setHtml(box, texts.map((t, i) => `
От: ${esc(t.sender)}
${esc(t.text)}
`).join('')); } function renderMedia(media) { const box = $('disk-media'); if (!media.length) return setHtml(box, '
Нет сохранённых файлов
'); setHtml(box, media.map((m) => { const url = `/ui/files/${encodeURIComponent(m.filename)}`; return `
От: ${esc(m.sender)}${formatBytes(m.size)}
${esc(m.filename)} ↗
${m.risky ? '
⚠ Потенциально опасный тип файла — не открывайте, если не ждали его.
' : ''} ${m.previewable ? `` : ''}
`; }).join('')); } async function fetchState() { const d = await (await api('/ui/state')).json(); lastData = d; renderHeader(d); renderScan(d.scan); renderTransfers(d.transfers); renderPeers(d.peers); renderTexts(d.texts); renderMedia(d.media); } // ───────────── действия ───────────── async function guarded(fn) { try { await fn(); } catch (e) { showStatus(e.message || String(e), true); } } function scanIpRange(full) { return guarded(async () => { const range = full ? 'all' : $('scan-range-input').value.trim(); if (!full && !range) return showStatus('Введите диапазон IP (например 192.168.1.1-254)', true); await postJson('/ui/scan-range', { range }); showStatus('Сканирование запущено…'); fetchState().catch(() => {}); }); } function addManualPeer() { return guarded(async () => { const raw = $('manual-ip-input').value.trim(); if (!raw) return showStatus('Введите IP-адрес устройства', true); let ip = raw, port = null; const i = raw.lastIndexOf(':'); if (i > 0) { ip = raw.slice(0, i); port = parseInt(raw.slice(i + 1), 10) || null; } showStatus(`Связь с узлом ${ip}…`); await postJson('/ui/manual-peer', port ? { ip, port } : { ip }); $('manual-ip-input').value = ''; showStatus(`Узел ${ip} найден. Сверьте код и заверьте устройство.`); fetchState().catch(() => {}); }); } function pingPeers() { return guarded(async () => { await api('/ui/ping', { method: 'POST' }); showStatus('Запущен опрос известных узлов'); setTimeout(() => fetchState().catch(() => {}), 800); }); } function setTrust(id, trust) { return guarded(async () => { await postJson('/ui/trust', { peer_id: id, trust }); showStatus(trust ? 'Устройство заверено' : 'Доверие отозвано'); fetchState().catch(() => {}); }); } function sendText() { return guarded(async () => { const peer_id = $('target-peer').value; const input = $('text-input'); const text = input.value.trim(); if (!peer_id) return showStatus('Выберите устройство-получатель!', true); if (!text) return showStatus('Введите текст для отправки!', true); if (new TextEncoder().encode(text).length > 64 * 1024) return showStatus('Текст больше 64 КБ', true); await postJson('/ui/send-text', { peer_id, text }); input.value = ''; showStatus('Текст доставлен в ОЗУ получателя'); }); } async function sendMedia() { const peer_id = $('target-peer').value; const fileInput = $('media-input'); const btn = $('send-media-btn'); if (!peer_id) return showStatus('Выберите устройство-получатель!', true); if (!fileInput.files.length) return showStatus('Выберите файл!', true); const file = fileInput.files[0]; btn.disabled = true; try { await api('/ui/send-media', { method: 'POST', headers: { 'X-Target-Id': peer_id, 'X-Filename': encodeURIComponent(file.name), 'Content-Type': 'application/octet-stream', }, body: file, }); fileInput.value = ''; showStatus(`Файл «${file.name}» передан`); } catch (e) { showStatus(e.message || String(e), true); } finally { btn.disabled = false; fetchState().catch(() => {}); } } function clearRam() { return guarded(async () => { await api('/ui/clear-ram', { method: 'POST' }); showStatus('Буфер текстов в ОЗУ очищен'); fetchState().catch(() => {}); }); } // ───────────── события (без inline-обработчиков: CSP script-src 'self') ───────────── $('btn-ping').addEventListener('click', pingPeers); $('btn-manual').addEventListener('click', addManualPeer); $('btn-scan-range').addEventListener('click', () => scanIpRange(false)); $('btn-scan-all').addEventListener('click', () => scanIpRange(true)); $('btn-send-text').addEventListener('click', sendText); $('send-media-btn').addEventListener('click', sendMedia); $('btn-clear-ram').addEventListener('click', clearRam); $('text-input').addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); sendText(); } }); $('peers-list').addEventListener('click', (e) => { const btn = e.target.closest('button[data-action="trust"]'); if (!btn || !lastData) return; const id = btn.dataset.id; if (btn.dataset.trust === '0') return setTrust(id, false); const peer = lastData.peers.find((p) => p.id === id); if (!peer) return; pendingTrustId = id; $('td-name').textContent = peer.name; $('td-code').textContent = peer.pin_code; $('trust-dialog').showModal(); }); $('td-cancel').addEventListener('click', () => { pendingTrustId = null; $('trust-dialog').close(); }); $('td-ok').addEventListener('click', () => { const id = pendingTrustId; pendingTrustId = null; $('trust-dialog').close(); if (id) setTrust(id, true); }); $('ram-texts').addEventListener('click', (e) => { const btn = e.target.closest('button[data-action="copy"]'); if (!btn || !lastData) return; const item = lastData.texts[Number(btn.dataset.idx)]; if (!item) return; navigator.clipboard.writeText(item.text).then( () => showStatus('Текст скопирован в буфер обмена'), () => showStatus('Не удалось скопировать', true), ); }); // ───────────── опрос состояния: следующий запрос только после завершения предыдущего ───────────── async function poll() { try { if (!document.hidden) await fetchState(); $('offline').style.display = 'none'; } catch (_) { $('offline').style.display = 'block'; } setTimeout(poll, document.hidden ? 2500 : 700); } poll();