a.0.1.0 move to creates src

This commit is contained in:
netadmin committed 2026-10-27 22:23:15 +03:00
1 parent 86d3a3b47f
commit f22d954105
942 files changed
+275635 -225

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
[package]
name = "ramshare_ui"
version.workspace = true
edition.workspace = true
[[bin]]
name = "ramshare_ui"
path = "src/main.rs"
[dependencies]
ramshare-core = { path = "../core" }
tokio.workspace = true
tokio-util.workspace = true
tokio-rustls.workspace = true
axum.workspace = true
serde_json.workspace = true
+482
View File
@@ -0,0 +1,482 @@
//! Консольный режим: те же возможности, что и в веб-интерфейсе, без запуска сервера.
use axum::http::StatusCode;
use ramshare_core::{models::*, net, transfer};
use std::{
io::{self, BufRead, IsTerminal, Read, Write},
net::IpAddr,
path::PathBuf,
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
time::{Duration, Instant},
};
use tokio_util::io::ReaderStream;
pub struct CliOpts {
pub json: bool,
pub wait: u64,
pub scan: Option<String>,
pub yes: bool,
pub confirm_wait: u64,
}
static DISCOVERY_STARTED: AtomicBool = AtomicBool::new(false);
fn ensure_discovery(state: &Arc<AppState>) {
if !DISCOVERY_STARTED.swap(true, Ordering::SeqCst) {
net::start_discovery(state.clone());
}
}
fn fmt_bytes(b: u64) -> String {
if b < 1024 {
format!("{} Б", b)
} else if b < 1024 * 1024 {
format!("{:.1} КБ", b as f64 / 1024.0)
} else if b < 1024 * 1024 * 1024 {
format!("{:.2} МБ", b as f64 / 1048576.0)
} else {
format!("{:.2} ГБ", b as f64 / 1073741824.0)
}
}
fn fmt_ago(ts: u64, now: u64) -> String {
if ts == 0 {
return "никогда".into();
}
let d = now.saturating_sub(ts);
if d < 3 {
"сейчас".into()
} else if d < 60 {
format!("{} с назад", d)
} else if d < 3600 {
format!("{} мин назад", d / 60)
} else if d < 86400 {
format!("{} ч назад", d / 3600)
} else {
format!("{} дн назад", d / 86400)
}
}
fn app_err(e: AppError) -> String {
if e.0 == StatusCode::FORBIDDEN {
format!("{} (выполните: ramshare_ui trust <устройство>)", e.1)
} else {
e.1
}
}
fn confirm(prompt: &str) -> bool {
eprint!("{} [y/N]: ", prompt);
let _ = io::stderr().flush();
let mut s = String::new();
io::stdin().lock().read_line(&mut s).is_ok()
&& matches!(s.trim().to_lowercase().as_str(), "y" | "yes" | "д" | "да")
}
fn snapshot(state: &AppState) -> Vec<PeerDevice> {
let mut v: Vec<PeerDevice> = state.peers.read().values().cloned().collect();
v.sort_by(|a, b| b.online.cmp(&a.online).then_with(|| a.name.cmp(&b.name)));
v
}
fn trunc(s: &str, n: usize) -> String {
s.chars().take(n).collect()
}
fn trust_label(p: &PeerDevice) -> &'static str {
match (p.verified, p.trusts_me) {
(true, true) => "взаимно",
(true, false) => "ждём",
(false, true) => "запрос",
_ => "нет",
}
}
fn print_peers(state: &AppState, json: bool, only_online: bool) {
let v: Vec<PeerDevice> = snapshot(state)
.into_iter()
.filter(|p| !only_online || p.online)
.collect();
if json {
println!("{}", serde_json::to_string_pretty(&v).unwrap_or_else(|_| "[]".into()));
return;
}
let now = now_sec();
let last_p = state.last_ping.load(Ordering::Relaxed);
eprintln!("Последний опрос (ping): {}", fmt_ago(last_p, now));
if v.is_empty() {
println!("Устройства не найдены.");
return;
}
println!(
"{:<9} {:<13} {:<13} {:<9} {:<20} {:<21} {:<24} {}",
"СТАТУС", "БЫЛ ОНЛАЙН", "ПОСЛ. ПИНГ", "ДОВЕРИЕ", "ИМЯ", "АДРЕС", "КОД СВЕРКИ", "ID"
);
for p in v {
let addr = p
.ips
.first()
.map(|ip| format!("{}:{}", ip, p.port))
.unwrap_or_else(|| "—".into());
println!(
"{:<9} {:<13} {:<13} {:<9} {:<20} {:<21} {:<24} {}",
if p.online { "● online" } else { "○ offline" },
fmt_ago(p.last_seen, now),
fmt_ago(p.last_ping, now),
trust_label(&p),
trunc(&p.name, 20),
addr,
p.pin_code,
short_id(&p.fingerprint)
);
}
}
fn stage_match(stage: u8, p: &PeerDevice, sel: &str, sel_l: &str) -> bool {
match stage {
0 => p.fingerprint == sel_l,
1 => p.ips.iter().any(|i| i == sel),
2 => p.name.to_lowercase() == sel_l,
3 => sel_l.len() >= 4 && sel_l.bytes().all(|b| b.is_ascii_hexdigit()) && p.fingerprint.starts_with(sel_l),
_ => p.name.to_lowercase().starts_with(sel_l),
}
}
fn resolve(state: &AppState, sel: &str) -> Result<Option<PeerDevice>, String> {
let sel_l = sel.to_lowercase();
let peers: Vec<PeerDevice> = state.peers.read().values().cloned().collect();
for stage in 0..5u8 {
let m: Vec<&PeerDevice> = peers.iter().filter(|p| stage_match(stage, p, sel, &sel_l)).collect();
match m.len() {
0 => continue,
1 => return Ok(Some(m[0].clone())),
_ => {
let names: Vec<String> = m
.iter()
.map(|p| format!("{} [{}]", p.name, short_id(&p.fingerprint)))
.collect();
return Err(format!(
"«{}» подходит нескольким устройствам: {}. Уточните (IP или префикс ID).",
sel,
names.join(", ")
));
}
}
}
Ok(None)
}
fn split_sel(sel: &str) -> (String, Option<u16>) {
if let Some((h, p)) = sel.rsplit_once(':') {
if let Ok(port) = p.parse::<u16>() {
if h.parse::<IpAddr>().is_ok() {
return (h.to_string(), Some(port));
}
}
}
(sel.to_string(), None)
}
async fn scan_with_progress(state: &Arc<AppState>, range: &str) -> Result<(), String> {
let my_ip = state.current_ip();
let (label, ips) = parse_ip_range(range, &my_ip)?;
let total = ips.len();
let port = state.p2p_port;
*state.scan_status.write() = ScanProgress {
active: true,
range: label.clone(),
scanned: 0,
total,
found: 0,
};
let tty = io::stderr().is_terminal();
if tty {
eprintln!("Сканирование {} ({} адресов, порт {})", label, total, port);
}
let worker = tokio::spawn(net::run_scan(state.clone(), ips, port));
while !worker.is_finished() {
if tty {
let s = state.scan_status.read().clone();
eprint!("\r проверено {}/{} найдено: {} ", s.scanned, s.total, s.found);
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
let _ = worker.await;
let s = {
let mut g = state.scan_status.write();
g.active = false;
g.clone()
};
if tty {
eprintln!("\r проверено {}/{} найдено: {} ", s.scanned, s.total, s.found);
}
Ok(())
}
async fn find_peer(
state: &Arc<AppState>,
sel: &str,
opts: &CliOpts,
need_online: bool,
) -> Result<PeerDevice, String> {
let (host, port_opt) = split_sel(sel);
net::ping_all_peers(state, true).await;
if let Some(p) = resolve(state, &host)? {
if p.online || !need_online {
return Ok(p);
}
}
if let Ok(IpAddr::V4(v4)) = host.parse::<IpAddr>() {
if is_lan_v4(v4) {
let port = port_opt.unwrap_or(state.p2p_port);
if net::probe_and_register(state, IpAddr::V4(v4), port).await.is_ok() {
if let Some(p) = resolve(state, &host)? {
return Ok(p);
}
}
}
}
ensure_discovery(state);
if io::stderr().is_terminal() {
eprintln!("Поиск «{}» (UDP-обнаружение, до {} с)…", sel, opts.wait);
}
let deadline = Instant::now() + Duration::from_secs(opts.wait);
while Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(300)).await;
if let Some(p) = resolve(state, &host)? {
if p.online || !need_online {
return Ok(p);
}
}
}
if let Some(r) = &opts.scan {
scan_with_progress(state, r).await?;
if let Some(p) = resolve(state, &host)? {
if p.online || !need_online {
return Ok(p);
}
}
}
Err(format!(
"устройство «{}» не найдено в сети. Убедитесь, что на нём запущен `ramshare_ui serve`, \
или укажите IP / используйте `scan` и `--scan <диапазон>`",
sel
))
}
pub async fn cmd_scan(state: &Arc<AppState>, range: &str, opts: &CliOpts) -> Result<(), String> {
net::ping_all_peers(state, true).await;
scan_with_progress(state, range).await?;
print_peers(state, opts.json, true);
Ok(())
}
pub async fn cmd_peers(state: &Arc<AppState>, opts: &CliOpts) -> Result<(), String> {
net::ping_all_peers(state, true).await;
ensure_discovery(state);
if let Some(r) = &opts.scan {
scan_with_progress(state, r).await?;
}
if io::stderr().is_terminal() {
eprintln!("Слушаю UDP-обнаружение {} с…", opts.wait);
}
tokio::time::sleep(Duration::from_secs(opts.wait)).await;
print_peers(state, opts.json, false);
Ok(())
}
pub async fn cmd_trust(state: &Arc<AppState>, sel: &str, opts: &CliOpts) -> Result<(), String> {
let p = find_peer(state, sel, opts, true).await?;
if p.verified && p.trusts_me {
println!("«{}» уже заверено взаимно.", p.name);
return Ok(());
}
if !p.verified {
eprintln!("Устройство: {}", p.name);
eprintln!("Адрес: {}:{}", p.ips.first().map(String::as_str).unwrap_or("—"), p.port);
eprintln!("ID: {}", p.fingerprint);
eprintln!("Код сверки: {}", p.pin_code);
if p.trusts_me {
eprintln!("«{}» уже подтвердил сверку на своей стороне и ждёт вашего подтверждения.", p.name);
}
eprintln!("Сравните код с тем, что «{}» показывает на своём экране для вашего устройства.", p.name);
eprintln!("Если коды различаются — соединение может перехватываться, НЕ заверяйте.");
if !opts.yes && !confirm("Коды совпадают, заверить?") {
return Err("отменено пользователем".into());
}
if let Some(x) = state.peers.write().get_mut(&p.id) {
x.verified = true;
x.fail_count = 0;
}
state.persist_trust();
println!("Вы подтвердили: {} [{}]", p.name, p.pin_code);
} else {
eprintln!("Вы уже подтвердили «{}», ждём подтверждения с его стороны.", p.name);
}
net::push_trust(state, &p.id).await;
let mutual = || {
state
.peers
.read()
.get(&p.id)
.map_or(false, |x| x.verified && x.trusts_me)
};
if mutual() {
state.persist_trust();
println!("✓ Взаимная сверка завершена: {}", p.name);
return Ok(());
}
if opts.confirm_wait == 0 {
eprintln!(
"Ожидается подтверждение на «{}»: там нужно сверить код и подтвердить (CLI: `trust`, UI: «Сверить и подтвердить»).",
p.name
);
eprintln!("Состояние: ramshare_ui peers (или повторите команду с --confirm-wait <сек>)");
return Ok(());
}
eprintln!("Ждём подтверждения на «{}» до {} с…", p.name, opts.confirm_wait);
let deadline = Instant::now() + Duration::from_secs(opts.confirm_wait);
while Instant::now() < deadline {
tokio::time::sleep(Duration::from_secs(2)).await;
net::push_trust(state, &p.id).await;
if mutual() {
state.persist_trust();
println!("✓ Взаимная сверка завершена: {}", p.name);
return Ok(());
}
}
Err(format!(
"«{}» не подтвердил сверку за {} с. Пока доверие одностороннее, передачи к нему не пройдут.",
p.name, opts.confirm_wait
))
}
pub async fn cmd_untrust(state: &Arc<AppState>, sel: &str, opts: &CliOpts) -> Result<(), String> {
let p = find_peer(state, sel, opts, false).await?;
if !p.verified {
println!("«{}» и так не заверено.", p.name);
return Ok(());
}
if let Some(x) = state.peers.write().get_mut(&p.id) {
x.verified = false;
}
state.persist_trust();
net::push_trust(state, &p.id).await;
println!("Доверие отозвано: {} [{}]", p.name, p.pin_code);
Ok(())
}
pub async fn cmd_send_text(
state: &Arc<AppState>,
sel: &str,
words: &[String],
opts: &CliOpts,
) -> Result<(), String> {
let text = if words.len() == 1 && words[0] == "-" {
let mut s = String::new();
io::stdin()
.take(MAX_TEXT_BYTES as u64 + 1)
.read_to_string(&mut s)
.map_err(|e| format!("чтение stdin: {}", e))?;
s
} else {
words.join(" ")
};
if text.trim().is_empty() {
return Err("пустой текст (укажите слова после имени устройства или «-» для stdin)".into());
}
let p = find_peer(state, sel, opts, true).await?;
let name = transfer::send_text(state, &p.id, text).await.map_err(app_err)?;
state.persist_trust();
println!("✓ Текст доставлен в ОЗУ устройства «{}»", name);
Ok(())
}
pub async fn cmd_send_files(
state: &Arc<AppState>,
sel: &str,
paths: &[PathBuf],
opts: &CliOpts,
) -> Result<(), String> {
let mut files: Vec<(PathBuf, u64)> = Vec::new();
for path in paths {
let meta = tokio::fs::metadata(path)
.await
.map_err(|e| format!("{}: {}", path.display(), e))?;
if !meta.is_file() {
return Err(format!("{}: это не обычный файл", path.display()));
}
files.push((path.clone(), meta.len()));
}
let p = find_peer(state, sel, opts, true).await?;
let tty = io::stderr().is_terminal();
for (path, size) in files {
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "file.bin".into());
let file = tokio::fs::File::open(&path)
.await
.map_err(|e| format!("{}: {}", path.display(), e))?;
let st = state.clone();
let prog = tokio::spawn(async move {
if !tty {
return;
}
let start = Instant::now();
loop {
tokio::time::sleep(Duration::from_millis(200)).await;
let cur = st
.transfers
.read()
.iter()
.find(|t| t.direction == Direction::Out && !t.done)
.cloned();
if let Some(t) = cur {
let speed = (t.transferred_bytes as f64 / start.elapsed().as_secs_f64().max(0.001)) as u64;
eprint!(
"\r {:>3}% {} / {} {}/с ",
t.percent,
fmt_bytes(t.transferred_bytes),
fmt_bytes(t.total_bytes),
fmt_bytes(speed)
);
}
}
});
let res = transfer::send_stream(
state,
&p.id,
&name,
size,
ReaderStream::with_capacity(file, 256 * 1024),
)
.await;
prog.abort();
if tty {
eprint!("\r{}\r", " ".repeat(72));
}
match res {
Ok(peer) => {
state.persist_trust();
println!("✓ {} → «{}» ({})", name, peer, fmt_bytes(size));
}
Err(e) => return Err(format!("{}: {}", name, app_err(e))),
}
}
Ok(())
}
+383
View File
@@ -0,0 +1,383 @@
mod cli;
use axum::{
http::header,
response::{Html, IntoResponse},
routing::get,
Router,
};
use ramshare_core::{api, init_app_state, models::*, net, p2p, storage, tls};
use std::{
net::Ipv4Addr,
path::PathBuf,
sync::{atomic::Ordering, Arc},
time::Duration,
};
const INDEX_HTML: &str = include_str!("static/index.html");
const APP_JS: &str = include_str!("static/app.js");
async fn ui_index() -> Html<&'static str> {
Html(INDEX_HTML)
}
async fn ui_js() -> impl IntoResponse {
([(header::CONTENT_TYPE, "text/javascript; charset=utf-8")], APP_JS)
}
const USAGE: &str = r#"ramshare_ui — обмен текстом и файлами в локальной сети (TLS 1.3, mTLS, пиннинг сертификатов)
Использование: ramshare_ui [параметры] [команда]
Команды:
serve запустить узел и веб-интерфейс (команда по умолчанию)
scan [диапазон] просканировать сеть и показать найденные устройства
(all, 192.168.1.1-254, 10.0.0.0/24, список через запятую)
peers показать, кто онлайн (известные + UDP-обнаружение)
config показать путь и значения config.json (создаёт файл при первом запуске)
trust <устройство> сверить код и заверить устройство (сверка взаимная: второе
устройство получит запрос и тоже должно подтвердить код)
untrust <устройство> отозвать доверие
send-text <устройство> <текст…|-> отправить текст ("-" — читать из stdin)
send-file <устройство> <файл…> отправить один или несколько файлов
<устройство> — имя, IP[:порт], префикс ID (≥4 hex) или полный отпечаток
Параметры:
--name <имя> имя устройства на этот запуск (постоянное задаётся в config.json)
--p2p-port <порт> порт P2P (HTTPS, mTLS), по умолчанию 8443; для scan/send — порт поиска
--ui-port <порт> порт локального веб-интерфейса (только 127.0.0.1), по умолчанию 8080
--ip <IPv4> адрес интерфейса (по умолчанию определяется автоматически)
--data-dir <путь> базовый каталог вместо папки с ramshare_ui; внутри создаются
config/ (config.json, trusted.json) и received_media/ (принятые файлы)
--ping-interval <с> интервал опроса известных устройств, 10–30 с (иначе config.json, иначе 15)
--no-ui (serve) без веб-интерфейса; входящий текст печатается в терминал
--wait <с> (CLI) сколько слушать UDP-обнаружение, 1–60, по умолчанию 4
--scan <диапазон> (CLI) если устройство не найдено — просканировать этот диапазон
--confirm-wait <с> (trust) ждать подтверждения на втором устройстве, 0–600 (по умолчанию 0)
--json (scan/peers) вывод в JSON
-y, --yes (trust) не спрашивать подтверждение (использовать осознанно!)
-h, --help эта справка
"#;
enum Command {
Serve,
Scan(Option<String>),
Peers,
Config,
Trust(String),
Untrust(String),
SendText { peer: String, words: Vec<String> },
SendFile { peer: String, paths: Vec<PathBuf> },
}
struct Config {
name: Option<String>,
p2p_port: u16,
ui_port: u16,
ip: Option<Ipv4Addr>,
data_dir: Option<PathBuf>,
ping_interval: Option<u64>,
json: bool,
wait: u64,
confirm_wait: u64,
scan: Option<String>,
yes: bool,
no_ui: bool,
command: Command,
}
fn fatal(msg: &str) -> ! {
eprintln!("Ошибка: {}", msg);
std::process::exit(1);
}
fn default_device_name() -> String {
let n = std::env::var("COMPUTERNAME")
.or_else(|_| std::env::var("HOSTNAME"))
.unwrap_or_default();
let n = clean_label(&n, 48);
if n.is_empty() {
"My-Device".into()
} else {
n
}
}
fn parse_args() -> Result<Config, String> {
let mut cfg = Config {
name: None,
p2p_port: 8443,
ui_port: 8080,
ip: None,
data_dir: None,
ping_interval: None,
json: false,
wait: 4,
confirm_wait: 0,
scan: None,
yes: false,
no_ui: false,
command: Command::Serve,
};
let mut positional: Vec<String> = Vec::new();
let mut only_pos = false;
let mut it = std::env::args().skip(1);
while let Some(a) = it.next() {
if !only_pos && a == "--" {
only_pos = true;
continue;
}
if only_pos || (!a.starts_with("--") && a != "-h" && a != "-y") {
positional.push(a);
continue;
}
let mut val = |flag: &str| it.next().ok_or_else(|| format!("{} требует значение", flag));
match a.as_str() {
"--name" => cfg.name = Some(clean_label(&val("--name")?, 48)),
"--p2p-port" => {
cfg.p2p_port = val("--p2p-port")?
.parse()
.map_err(|_| "Некорректный --p2p-port".to_string())?
}
"--ui-port" => {
cfg.ui_port = val("--ui-port")?
.parse()
.map_err(|_| "Некорректный --ui-port".to_string())?
}
"--ip" => {
cfg.ip = Some(
val("--ip")?
.parse()
.map_err(|_| "Некорректный --ip (нужен IPv4)".to_string())?,
)
}
"--data-dir" => cfg.data_dir = Some(PathBuf::from(val("--data-dir")?)),
"--ping-interval" => {
let s: u64 = val("--ping-interval")?
.parse()
.map_err(|_| "Некорректный --ping-interval".to_string())?;
if !(MIN_PING_SEC..=MAX_PING_SEC).contains(&s) {
return Err(format!("--ping-interval: допустимо {}–{} секунд", MIN_PING_SEC, MAX_PING_SEC));
}
cfg.ping_interval = Some(s);
}
"--wait" => {
let w: u64 = val("--wait")?.parse().map_err(|_| "Некорректный --wait".to_string())?;
if !(1..=60).contains(&w) {
return Err("--wait: допустимо 1–60 секунд".into());
}
cfg.wait = w;
}
"--scan" => cfg.scan = Some(val("--scan")?),
"--confirm-wait" => {
let w: u64 = val("--confirm-wait")?
.parse()
.map_err(|_| "Некорректный --confirm-wait".to_string())?;
if w > 600 {
return Err("--confirm-wait: допустимо 0–600 секунд".into());
}
cfg.confirm_wait = w;
}
"--json" => cfg.json = true,
"--no-ui" => cfg.no_ui = true,
"-y" | "--yes" => cfg.yes = true,
"-h" | "--help" => {
println!("{}", USAGE);
std::process::exit(0);
}
other => return Err(format!("Неизвестный параметр: {}\n\n{}", other, USAGE)),
}
}
let mut pos = positional.into_iter();
let sub = pos.next();
cfg.command = match sub.as_deref() {
None | Some("serve") => Command::Serve,
Some("scan") => Command::Scan(pos.next()),
Some("peers") | Some("list") | Some("ls") => Command::Peers,
Some("config") => Command::Config,
Some("trust") => Command::Trust(pos.next().ok_or("trust: укажите устройство")?),
Some("untrust") => Command::Untrust(pos.next().ok_or("untrust: укажите устройство")?),
Some("send-text") | Some("text") => {
let peer = pos.next().ok_or("send-text: укажите устройство")?;
let words: Vec<String> = pos.collect();
if words.is_empty() {
return Err("send-text: укажите текст (или «-» для чтения из stdin)".into());
}
Command::SendText { peer, words }
}
Some("send-file") | Some("file") => {
let peer = pos.next().ok_or("send-file: укажите устройство")?;
let paths: Vec<PathBuf> = pos.map(PathBuf::from).collect();
if paths.is_empty() {
return Err("send-file: укажите хотя бы один файл".into());
}
Command::SendFile { peer, paths }
}
Some("help") => {
println!("{}", USAGE);
std::process::exit(0);
}
Some(other) => return Err(format!("Неизвестная команда: {}\n\n{}", other, USAGE)),
};
if cfg.name.as_deref() == Some("") {
return Err("Пустое имя устройства".into());
}
if cfg.p2p_port == 0 || cfg.ui_port == 0 {
return Err("Порт не может быть 0".into());
}
Ok(cfg)
}
async fn run_serve(cfg: &Config, state: Arc<AppState>, bind_ip: Ipv4Addr) {
let server_cfg = tls::server_config(&state.cert_der, &state.key_der).unwrap_or_else(|e| fatal(&e));
let acceptor = tokio_rustls::TlsAcceptor::from(server_cfg);
let p2p_listener = tokio::net::TcpListener::bind((bind_ip, cfg.p2p_port))
.await
.unwrap_or_else(|e| fatal(&format!("P2P-порт {}:{} недоступен: {}", bind_ip, cfg.p2p_port, e)));
let ui_listener = if cfg.no_ui {
None
} else {
Some(
tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, cfg.ui_port))
.await
.unwrap_or_else(|e| fatal(&format!("UI-порт {} недоступен: {}", cfg.ui_port, e))),
)
};
state.add_log(format!(
"Узел '{}' запущен на {}:{} (ID: {})",
state.device_name,
state.current_ip(),
cfg.p2p_port,
short_id(&state.my_fingerprint)
));
net::start_discovery(state.clone());
{
let st = state.clone();
tokio::spawn(async move {
net::ping_all_peers(&st, false).await;
st.maintenance();
loop {
let secs = st.ping_interval_sec.load(Ordering::Relaxed);
tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(secs)) => {}
_ = st.ping_notify.notified() => {}
}
net::ping_all_peers(&st, false).await;
st.maintenance();
}
});
}
println!("==============================================================");
println!(" Устройство: {}", state.device_name);
println!(" P2P (HTTPS, mTLS): {}:{}", bind_ip, cfg.p2p_port);
println!(" Адрес в сети: {}", state.current_ip());
println!(" ID устройства: {}", short_id(&state.my_fingerprint));
println!(" Папка config: {}", state.data_dir.display());
println!(" Файл настроек: {}", state.data_dir.join("config.json").display());
println!(" Принятые файлы: {}", state.media_dir.display());
println!(
" Опрос устройств: каждые {} с (до {} попыток при недоступности)",
state.ping_interval_sec.load(Ordering::Relaxed),
MAX_PING_FAILS
);
println!("--------------------------------------------------------------");
let ui_task = match ui_listener {
Some(listener) => {
println!(" ОТКРОЙТЕ ВЕБ-ИНТЕРФЕЙС:");
println!(" http://127.0.0.1:{}/", cfg.ui_port);
let web_assets = Router::new()
.route("/", get(ui_index))
.route("/app.js", get(ui_js));
let ui_router = api::control_router(web_assets, state.clone(), cfg.ui_port);
Some(tokio::spawn(async move { axum::serve(listener, ui_router).await }))
}
None => {
println!(" Режим без UI (--no-ui). Входящий текст печатается в терминал,");
println!(" файлы сохраняются в {}", state.media_dir.display());
None
}
};
println!("==============================================================");
let p2p_task = tokio::spawn(p2p::serve(p2p_listener, acceptor, p2p::router(state.clone())));
let ui_wait = async {
match ui_task {
Some(t) => eprintln!("UI-сервер остановился: {:?}", t.await),
None => std::future::pending::<()>().await,
}
};
tokio::select! {
_ = ui_wait => {}
r = p2p_task => eprintln!("P2P-сервер остановился: {:?}", r),
_ = tokio::signal::ctrl_c() => println!("\nЗавершение работы…"),
}
state.sync_trust_from_disk();
state.persist_trust();
storage::cleanup_partials(&state.media_dir);
}
#[tokio::main]
async fn main() {
let cfg = parse_args().unwrap_or_else(|e| fatal(&e));
let serving = matches!(cfg.command, Command::Serve);
let dirs = storage::resolve_dirs(cfg.data_dir.as_deref(), cfg.p2p_port).unwrap_or_else(|e| fatal(&e));
let (state, bind_ip) = init_app_state(
dirs.config,
dirs.media,
cfg.name.clone(),
&default_device_name(),
cfg.p2p_port,
cfg.ip,
cfg.ping_interval,
serving,
serving && cfg.no_ui,
)
.unwrap_or_else(|e| fatal(&e));
if serving {
run_serve(&cfg, state, bind_ip).await;
return;
}
let opts = cli::CliOpts {
json: cfg.json,
wait: cfg.wait,
scan: cfg.scan.clone(),
yes: cfg.yes,
confirm_wait: cfg.confirm_wait,
};
let res = match &cfg.command {
Command::Scan(r) => cli::cmd_scan(&state, r.as_deref().unwrap_or("all"), &opts).await,
Command::Peers => cli::cmd_peers(&state, &opts).await,
Command::Trust(s) => cli::cmd_trust(&state, s, &opts).await,
Command::Untrust(s) => cli::cmd_untrust(&state, s, &opts).await,
Command::SendText { peer, words } => cli::cmd_send_text(&state, peer, words, &opts).await,
Command::SendFile { peer, paths } => cli::cmd_send_files(&state, peer, paths, &opts).await,
Command::Config => {
println!("Файл конфигурации: {}", state.data_dir.join("config.json").display());
println!("device_name: {}", state.device_name);
println!("ping_interval_sec: {}", state.ping_interval_sec.load(Ordering::Relaxed));
println!("identity.fingerprint: {}", state.my_fingerprint);
Ok(())
}
Command::Serve => Ok(()),
};
if let Err(e) = res {
eprintln!("Ошибка: {}", e);
std::process::exit(1);
}
}
+486
View File
@@ -0,0 +1,486 @@
'use strict';
const $ = (id) => document.getElementById(id);
let statusTimer = null;
let lastData = null;
let knownPeersKey = '';
let lastSubnet = '';
let pendingTrustId = null;
function esc(v) {
return String(v).replace(/[&<>"']/g, (s) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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)} ГБ`;
}
function formatTimeAgo(ts, nowSec) {
if (!ts) return 'никогда';
const now = nowSec || Math.floor(Date.now() / 1000);
const diff = Math.max(0, now - ts);
const dt = new Date(ts * 1000);
const timeStr = dt.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
if (diff < 3) return `только что (${timeStr})`;
if (diff < 60) return `${diff} с назад (${timeStr})`;
if (diff < 3600) return `${Math.floor(diff / 60)} мин назад (${timeStr})`;
if (diff < 86400) return `${Math.floor(diff / 3600)} ч назад (${timeStr})`;
const dateStr = dt.toLocaleDateString('ru-RU');
return `${dateStr} ${timeStr}`;
}
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 switchTab(tab) {
const isMain = tab === 'main';
$('view-main').style.display = isMain ? 'grid' : 'none';
$('view-trusted').style.display = isMain ? 'none' : 'block';
$('tab-btn-main').classList.toggle('active', isMain);
$('tab-btn-trusted').classList.toggle('active', !isMain);
}
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;
const pingTxt = `Последний пинг: ${formatTimeAgo(d.last_ping, d.now)}`;
$('last-ping-info').textContent = pingTxt;
$('trusted-last-ping').textContent = pingTxt;
const pi = $('ping-interval-input');
pi.min = d.ping_min;
pi.max = d.ping_max;
if (document.activeElement !== pi) pi.value = d.ping_interval_sec;
if (d.my_ip && d.my_ip.includes('.') && !d.my_ip.startsWith('127.')) {
const p = d.my_ip.split('.');
const subnet = `${p[0]}.${p[1]}.${p[2]}`;
if (subnet !== lastSubnet) {
lastSubnet = subnet;
$('scan-range-input').value = `${subnet}.1-254`;
}
}
}
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;
$('btn-scan-all-trusted').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
? `<span style="color:#f87171;">${esc(t.error)}</span>`
: (t.done ? 'Готово' : 'Передача по TLS…');
return `
<div class="progress-wrap">
<div style="display:flex;justify-content:space-between;font-size:.83rem;font-weight:600;gap:8px;">
<span>${dir} ${esc(t.peer_name)}: <span style="color:#60a5fa;">${esc(t.filename)}</span></span>
<span>${esc(status)}</span>
</div>
<div class="progress-bar-bg"><div class="progress-bar-fill" style="width:${Number(t.percent)}%;"></div></div>
<div class="small muted" style="display:flex;justify-content:space-between;gap:8px;">
<span>${formatBytes(t.transferred_bytes)} из ${formatBytes(t.total_bytes)}</span>
<span>${note}</span>
</div>
</div>`;
}).join(''));
}
const notifiedRequests = new Set();
function trustInfo(p) {
if (p.verified && p.trusts_me) return { color: '#34d399', text: 'Взаимно заверено', btn: 'Отозвать', btnCls: 'btn-red', trust: '0', req: false };
if (p.verified) return { color: '#fbbf24', text: `Вы подтвердили. Ждём подтверждения на «${p.name}»`, btn: 'Отозвать', btnCls: 'btn-red', trust: '0', req: false };
if (p.trusts_me) return { color: '#fb923c', text: `«${p.name}» подтвердил сверку и ждёт вашего подтверждения`, btn: 'Сверить и подтвердить', btnCls: 'btn-green', trust: '1', req: true };
return { color: '#f87171', text: 'Не заверен', btn: 'Заверить', btnCls: 'btn-green', trust: '1', req: false };
}
function renderPeers(peers, nowSec, maxFails) {
const activePeers = peers.filter((p) => p.online);
const trustedPeers = peers.filter((p) => p.verified);
$('badge-active').textContent = String(activePeers.length);
$('badge-trusted').textContent = String(trustedPeers.length);
const pending = new Set(peers.filter((p) => !p.verified && p.trusts_me).map((p) => p.id));
for (const p of peers) {
if (pending.has(p.id) && !notifiedRequests.has(p.id)) {
notifiedRequests.add(p.id);
showStatus(`🔔 «${p.name}» запрашивает сверку. Сравните код и подтвердите.`);
}
}
for (const id of [...notifiedRequests]) if (!pending.has(id)) notifiedRequests.delete(id);
const mainBox = $('peers-list');
if (!activePeers.length) {
setHtml(mainBox, '<div class="muted" style="font-size:.9rem;">Активных устройств сейчас нет. Нажмите «Вся подсеть», «Опросить всех известных» или перейдите во вкладку «Заверенные устройства».</div>');
} else {
setHtml(mainBox, activePeers.map((p) => {
const t = trustInfo(p);
const seenLabel = `сейчас онлайн (активность: ${formatTimeAgo(p.last_seen, nowSec)})`;
const pingLabel = formatTimeAgo(p.last_ping, nowSec);
return `
<div class="peer-item${t.req ? ' request' : ''}">
<div style="min-width:0;">
<div style="font-weight:600;">
<span class="dot dot-on"></span>${esc(p.name)}
<span class="muted small">(${esc(p.ips.join(', ') || 'адрес неизвестен')}:${esc(p.port)})</span>
</div>
<div class="small muted mono" style="margin-top:3px;word-break:break-all;">
Код сверки: <b style="color:#fff;">${esc(p.pin_code)}</b><br>
Статус: <span style="color:${t.color};">${esc(t.text)}</span><br>
Был онлайн: <span style="color:#cbd5e1;">${esc(seenLabel)}</span> | Пинг: <span style="color:#cbd5e1;">${esc(pingLabel)}</span>
</div>
</div>
<div class="peer-actions">
<button class="btn-sm" data-action="ping-one" data-id="${esc(p.id)}">Пинг</button>
<button class="btn-sm ${t.btnCls}" data-action="trust" data-id="${esc(p.id)}" data-trust="${t.trust}">
${esc(t.btn)}
</button>
</div>
</div>`;
}).join(''));
}
const trustedBox = $('trusted-peers-list');
if (!trustedPeers.length) {
setHtml(trustedBox, '<div class="muted" style="font-size:.9rem;">У вас пока нет заверенных устройств. Найдите устройство на главной вкладке и нажмите «Заверить».</div>');
} else {
setHtml(trustedBox, trustedPeers.map((p) => {
const t = trustInfo(p);
const seenLabel = p.online
? `сейчас онлайн (активность: ${formatTimeAgo(p.last_seen, nowSec)})`
: formatTimeAgo(p.last_seen, nowSec);
const pingLabel = formatTimeAgo(p.last_ping, nowSec);
const limit = maxFails || 5;
const failNote = p.online
? '<span style="color:#34d399;">на связи (0 ошибок)</span>'
: p.fail_count >= limit
? `<span style="color:#f87171;">не ответил ${p.fail_count}/${limit} раз — автоопрос остановлен (нажмите «Пинг» или «Сканировать»)</span>`
: `<span style="color:#fbbf24;">оффлайн (попыток пинга: ${p.fail_count}/${limit})</span>`;
return `
<div class="peer-item">
<div style="min-width:0;">
<div style="font-weight:600;">
<span class="dot ${p.online ? 'dot-on' : 'dot-off'}"></span>${esc(p.name)}
<span class="muted small">(${esc(p.ips.join(', ') || 'адрес неизвестен')}:${esc(p.port)})</span>
</div>
<div class="small muted mono" style="margin-top:3px;word-break:break-all;">
Код сверки: <b style="color:#fff;">${esc(p.pin_code)}</b> | ID: ${esc(p.fingerprint.slice(0, 8).toUpperCase())}<br>
Доверие: <span style="color:${t.color};">${esc(t.text)}</span><br>
Был онлайн: <span style="color:#cbd5e1;">${esc(seenLabel)}</span> | Посл. пинг: <span style="color:#cbd5e1;">${esc(pingLabel)}</span><br>
Опрос: ${failNote}
</div>
</div>
<div class="peer-actions">
<button class="btn-sm" data-action="ping-one" data-id="${esc(p.id)}">Пинг</button>
<button class="btn-sm btn-red" data-action="trust" data-id="${esc(p.id)}" data-trust="0">Отозвать</button>
</div>
</div>`;
}).join(''));
}
const key = activePeers.map((p) => `${p.id}:${p.verified}:${p.trusts_me}:${p.ips[0] || ''}:${p.name}`).join('|');
if (key !== knownPeersKey) {
knownPeersKey = key;
const sel = $('target-peer');
const prev = sel.value;
sel.innerHTML = '<option value="">-- Выберите активное устройство --</option>' + activePeers.map((p) => {
const mark = p.verified && p.trusts_me ? '✓' : p.verified ? '⏳ ждёт подтверждения' : p.trusts_me ? '🔔 запрос на сверку' : '🔒 не заверен';
return `<option value="${esc(p.id)}">${esc(p.name)} (${esc(p.ips[0] || '—')}) ${mark}</option>`;
}).join('');
if (prev) sel.value = prev;
}
}
function renderTexts(texts) {
const box = $('ram-texts');
if (!texts.length) return setHtml(box, '<div class="muted" style="font-size:.9rem;">Буфер ОЗУ пуст</div>');
setHtml(box, texts.map((t, i) => `
<div class="msg-item">
<div class="msg-meta">
<span>От: <b>${esc(t.sender)}</b></span>
<button class="btn-sm" data-action="copy" data-idx="${i}">Скопировать</button>
</div>
<div class="msg-text">${esc(t.text)}</div>
</div>`).join(''));
}
function renderMedia(media) {
const box = $('disk-media');
if (!media.length) return setHtml(box, '<div class="muted" style="font-size:.9rem;">Нет сохранённых файлов</div>');
setHtml(box, media.map((m) => {
const url = `/ui/files/${encodeURIComponent(m.filename)}`;
return `
<div class="msg-item">
<div class="msg-meta"><span>От: <b>${esc(m.sender)}</b></span><span>${formatBytes(m.size)}</span></div>
<div style="font-weight:600;margin:2px 0;word-break:break-all;">
<a href="${url}" target="_blank" rel="noopener" style="color:#60a5fa;text-decoration:none;">${esc(m.filename)} ↗</a>
</div>
${m.risky ? '<div class="warn">⚠ Потенциально опасный тип файла — не открывайте, если не ждали его.</div>' : ''}
${m.previewable ? `<img src="${url}" class="preview-img" alt="">` : ''}
</div>`;
}).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, d.now, d.max_ping_fails);
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 saveSettings() {
return guarded(async () => {
const el = $('ping-interval-input');
const v = parseInt(el.value, 10);
const min = Number(el.min) || 10, max = Number(el.max) || 30;
if (!Number.isInteger(v) || v < min || v > max) return showStatus(`Интервал должен быть от ${min} до ${max} секунд`, true);
await postJson('/ui/settings', { ping_interval_sec: v });
showStatus(`Опрос устройств каждые ${v} с`);
el.blur();
fetchState().catch(() => {});
});
}
function pingPeers() {
return guarded(async () => {
$('btn-ping').disabled = true;
$('btn-ping-trusted').disabled = true;
try {
await api('/ui/ping', { method: 'POST' });
showStatus('Ручной опрос выполнен (счётчики попыток сброшены)');
await fetchState();
} finally {
$('btn-ping').disabled = false;
$('btn-ping-trusted').disabled = false;
}
});
}
function pingOnePeer(id, btn) {
return guarded(async () => {
if (btn) btn.disabled = true;
try {
const res = await (await postJson('/ui/ping-peer', { peer_id: id })).json();
const peer = lastData && lastData.peers.find((p) => p.id === id);
const name = peer ? peer.name : id.slice(0, 8);
if (res.online) {
showStatus(`✓ Устройство «${name}» онлайн!`);
} else {
showStatus(`Устройство «${name}» не ответило (автоопрос возобновлён на 5 попыток)`, true);
}
await fetchState();
} finally {
if (btn) btn.disabled = false;
}
});
}
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(() => {});
});
}
function handlePeerListClick(e) {
const pingBtn = e.target.closest('button[data-action="ping-one"]');
if (pingBtn) {
pingOnePeer(pingBtn.dataset.id, pingBtn);
return;
}
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();
}
$('tab-btn-main').addEventListener('click', () => switchTab('main'));
$('tab-btn-trusted').addEventListener('click', () => switchTab('trusted'));
$('btn-ping').addEventListener('click', pingPeers);
$('btn-ping-trusted').addEventListener('click', pingPeers);
$('btn-save-settings').addEventListener('click', saveSettings);
$('ping-interval-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') saveSettings(); });
$('btn-manual').addEventListener('click', addManualPeer);
$('btn-scan-range').addEventListener('click', () => scanIpRange(false));
$('btn-scan-all').addEventListener('click', () => scanIpRange(true));
$('btn-scan-all-trusted').addEventListener('click', () => {
switchTab('main');
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', handlePeerListClick);
$('trusted-peers-list').addEventListener('click', handlePeerListClick);
$('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();
+192
View File
@@ -0,0 +1,192 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LAN Share</title>
<style>
:root { --bg: #0f172a; --card: #1e293b; --border: #334155; --accent: #3b82f6; --green: #10b981; --red: #ef4444; --text: #f8fafc; --muted: #94a3b8; }
* { box-sizing: border-box; font-family: system-ui, -apple-system, sans-serif; }
body { margin: 0; padding: 20px; background: var(--bg); color: var(--text); }
.container { max-width: 1120px; margin: 0 auto; display: flex; flex-direction: column; gap: 18px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
@media (max-width: 800px) { .grid-2 { grid-template-columns: 1fr; } }
.header { background: var(--card); border: 1px solid var(--border); padding: 16px 20px; border-radius: 12px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; }
.pin-badge { background: #1d4ed8; padding: 6px 14px; border-radius: 8px; font-weight: 700; letter-spacing: 1px; font-family: monospace; font-size: 1.15rem; }
.tabs-bar { display: flex; gap: 10px; background: var(--card); border: 1px solid var(--border); padding: 8px; border-radius: 12px; flex-wrap: wrap; }
.tab-btn { background: transparent; color: var(--muted); border: 1px solid transparent; border-radius: 8px; padding: 9px 16px; font-weight: 600; cursor: pointer; display: flex; align-items: center; gap: 8px; }
.tab-btn:hover { color: var(--text); background: rgba(255,255,255,.04); }
.tab-btn.active { background: var(--accent); color: #fff; }
.badge { background: rgba(15,23,42,.55); padding: 2px 8px; border-radius: 999px; font-size: .78rem; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 18px; display: flex; flex-direction: column; gap: 14px; min-width: 0; }
h2 { margin: 0; font-size: 1.08rem; display: flex; justify-content: space-between; align-items: center; gap: 8px; flex-wrap: wrap; }
.tag { font-size: 0.75rem; padding: 3px 8px; border-radius: 6px; font-weight: 600; }
.tag-ram { background: rgba(16,185,129,.2); color: #34d399; }
.tag-disk { background: rgba(59,130,246,.2); color: #60a5fa; }
input[type="text"], input[type="number"], select, textarea, input[type="file"] { width: 100%; background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 10px; font-size: .92rem; }
textarea { resize: vertical; min-height: 80px; }
button { background: var(--accent); color: #fff; border: none; border-radius: 8px; padding: 10px 16px; font-weight: 600; cursor: pointer; transition: opacity .15s; }
button:hover { opacity: .9; }
button:disabled { opacity: .5; cursor: not-allowed; }
.btn-green { background: var(--green); }
.btn-red { background: var(--red); }
.btn-gray { background: #475569; }
.btn-sm { padding: 7px 12px; font-size: .82rem; }
.peer-item, .msg-item { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 12px; display: flex; justify-content: space-between; align-items: center; gap: 10px; }
.peer-actions { display: flex; gap: 8px; flex-wrap: wrap; flex-shrink: 0; }
.msg-item { flex-direction: column; align-items: flex-start; }
.msg-meta { width: 100%; display: flex; justify-content: space-between; font-size: .8rem; color: var(--muted); }
.msg-text { font-size: .95rem; white-space: pre-wrap; word-break: break-word; width: 100%; margin: 4px 0; }
.preview-img { max-width: 100%; max-height: 180px; border-radius: 6px; margin-top: 6px; }
.peer-item.request { border-color: #fb923c; background: rgba(251,146,60,.08); }
.warn { color: #fbbf24; font-size: .8rem; }
.muted { color: var(--muted); }
.small { font-size: .8rem; }
.mono { font-family: monospace; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
.dot-on { background: #10b981; }
.dot-off { background: #64748b; }
.progress-wrap { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; }
.progress-bar-bg { width: 100%; height: 10px; background: #0b1120; border-radius: 6px; overflow: hidden; border: 1px solid var(--border); }
.progress-bar-fill { height: 100%; background: linear-gradient(90deg, #3b82f6, #10b981); transition: width .2s ease; }
.sub-box { background: rgba(15,23,42,.55); border: 1px solid var(--border); border-radius: 8px; padding: 10px; display: flex; flex-direction: column; gap: 8px; }
.row { display: flex; gap: 8px; flex-wrap: wrap; }
.col { display: flex; flex-direction: column; gap: 8px; }
#status { padding: 10px 16px; border-radius: 8px; display: none; font-weight: 500; }
#offline { padding: 10px 16px; border-radius: 8px; display: none; background: rgba(239,68,68,.25); border: 1px solid var(--red); }
dialog { background: var(--card); color: var(--text); border: 1px solid var(--border); border-radius: 12px; padding: 22px; max-width: 460px; }
dialog::backdrop { background: rgba(0,0,0,.65); }
.sas { font-family: monospace; font-size: 1.5rem; font-weight: 700; letter-spacing: 2px; background: #1d4ed8; border-radius: 8px; padding: 12px; text-align: center; margin: 14px 0; word-break: break-all; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div>
<div style="font-size:1.2rem;font-weight:700;" id="my-name">Загрузка...</div>
<div class="small muted" id="my-ip"></div>
</div>
<div>
<span class="small muted" style="margin-right:8px;">ID устройства:</span>
<span class="pin-badge" id="my-pin">--------</span>
</div>
</div>
<div class="tabs-bar">
<button class="tab-btn active" id="tab-btn-main">
<span>Главная (Активные в сети)</span>
<span class="badge" id="badge-active">0</span>
</button>
<button class="tab-btn" id="tab-btn-trusted">
<span>Заверенные устройства</span>
<span class="badge" id="badge-trusted">0</span>
</button>
</div>
<div id="offline">Нет связи с приложением. Проверьте, что оно запущено.</div>
<div id="status"></div>
<!-- ВКЛАДКА 1: ГЛАВНАЯ (ТОЛЬКО АКТИВНЫЕ) -->
<div id="view-main" class="grid-2">
<div class="card">
<h2>
<span>1. Активные устройства в сети</span>
<button class="btn-sm" id="btn-ping">Опросить всех известных</button>
</h2>
<div class="row" style="flex-wrap:nowrap;">
<input type="text" id="manual-ip-input" placeholder="Один IP[:порт] (напр. 192.168.1.55 или 192.168.1.55:8443)">
<button class="btn-sm" style="white-space:nowrap;" id="btn-manual">Найти IP</button>
</div>
<div class="sub-box">
<div class="small muted" style="font-weight:600;">Сканер диапазона IP-адресов (только локальные сети):</div>
<div class="row">
<input type="text" id="scan-range-input" style="flex:1;min-width:190px;" placeholder="Напр. 192.168.1.1-254, 10.0.0.0/24">
<button class="btn-sm" id="btn-scan-range">Сканировать диапазон</button>
<button class="btn-sm btn-green" id="btn-scan-all">Вся подсеть (.1–254)</button>
</div>
<div id="scan-progress-box" style="display:none;">
<div class="small muted" style="display:flex;justify-content:space-between;margin-bottom:4px;">
<span id="scan-label">Сканирование...</span>
<span id="scan-numbers">0 / 0</span>
</div>
<div class="progress-bar-bg"><div class="progress-bar-fill" id="scan-bar-fill" style="width:0%;"></div></div>
</div>
</div>
<div class="row" style="align-items:center;justify-content:space-between;">
<div class="row" style="align-items:center;">
<label class="small muted" for="ping-interval-input">Опрос каждые</label>
<input type="number" id="ping-interval-input" min="10" max="30" step="1" style="width:74px;">
<span class="small muted">сек (10–30)</span>
<button class="btn-sm" id="btn-save-settings">Сохранить</button>
</div>
<div class="small muted" id="last-ping-info">Последний пинг: —</div>
</div>
<div id="peers-list" class="col"></div>
<div class="small muted">На главной показаны только устройства, которые сейчас онлайн. Если устройство не отвечает 5 раз подряд, его автоопрос прекращается (посмотреть все заверенные и пингануть вручную можно во вкладке «Заверенные устройства»).</div>
</div>
<div class="card">
<h2>2. Отправка данных</h2>
<label class="small muted">Активный получатель (должен быть заверен):</label>
<select id="target-peer"><option value="">-- Выберите активное устройство --</option></select>
<textarea id="text-input" placeholder="Введите текст (Enter — отправить, Shift+Enter — новая строка, до 64 КБ)..."></textarea>
<button id="btn-send-text">Отправить текст в ОЗУ</button>
<hr style="border:0;border-top:1px solid var(--border);width:100%;margin:4px 0;">
<input type="file" id="media-input">
<button class="btn-green" id="send-media-btn">Отправить файл на диск получателя</button>
<div id="transfers-list" class="col"></div>
</div>
<div class="card">
<h2>
<span>Входящий текст <span class="tag tag-ram">ТОЛЬКО В ОЗУ</span></span>
<button class="btn-red btn-sm" id="btn-clear-ram">Очистить ОЗУ</button>
</h2>
<div id="ram-texts" class="col" style="max-height:400px;overflow-y:auto;"></div>
</div>
<div class="card">
<h2>Входящие файлы <span class="tag tag-disk">НА ДИСКЕ</span></h2>
<div class="small muted mono" id="media-dir"></div>
<div id="disk-media" class="col" style="max-height:400px;overflow-y:auto;"></div>
</div>
</div>
<!-- ВКЛАДКА 2: ВСЕ ЗАВЕРЕННЫЕ ПИРЫ -->
<div id="view-trusted" style="display:none;">
<div class="card">
<h2>
<span>Все заверенные устройства (онлайн и оффлайн)</span>
<div class="row">
<button class="btn-sm" id="btn-ping-trusted">Пингануть все заверенные</button>
<button class="btn-sm btn-green" id="btn-scan-all-trusted">Сканировать всю подсеть</button>
</div>
</h2>
<div class="small muted" id="trusted-last-ping">Последний общий пинг: —</div>
<div id="trusted-peers-list" class="col"></div>
<div class="small muted">Если устройство недоступно, узел пробует пингануть его 5 раз. После 5 неудач устройство переводится в offline и больше не опрашивается автоматически, пока вы не нажмёте «Пинг» или «Сканировать всю подсеть».</div>
</div>
</div>
</div>
<dialog id="trust-dialog">
<h3 style="margin:0 0 8px;">Заверить устройство?</h3>
<div>«<b id="td-name"></b>»</div>
<div class="sas" id="td-code"></div>
<div class="small muted">Код должен <b>полностью совпадать</b> с кодом, который это устройство показывает у вас в списке. Если коды различаются, вас перехватывают — не заверяйте.<br><br>После вашего подтверждения то же самое будет запрошено на втором устройстве: доверие считается установленным, только когда код подтвердили <b>оба</b>.</div>
<div class="row" style="margin-top:16px;justify-content:flex-end;">
<button class="btn-gray" id="td-cancel">Отмена</button>
<button class="btn-green" id="td-ok">Коды совпадают — заверить</button>
</div>
</dialog>
<script src="/app.js"></script>
</body>
</html>