'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)}
${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();