speer-chat 0.2.1

terminal chat app for speer
use crate::app::{AppState, NetLevel, PeerHandle, RxFile, TxFile};
use crate::constants::{
    CHAT_TYPE_FILE_ACCEPT, CHAT_TYPE_FILE_CHUNK, CHAT_TYPE_FILE_DONE, CHAT_TYPE_FILE_META,
    FILE_CHUNK_BYTES,
};
use crate::util::{hex_decode, hex_encode, sanitize_file_name, unix_time_secs};
use std::fs::{self, File};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::Ordering;
use std::sync::Arc;

pub fn handle_file_frame(app: &Arc<AppState>, peer: &PeerHandle, kind: u32, payload: &str) {
    match kind {
        CHAT_TYPE_FILE_META => receive_file_meta(app, peer, payload),
        CHAT_TYPE_FILE_CHUNK => receive_file_chunk(app, peer, payload),
        CHAT_TYPE_FILE_DONE => receive_file_done(app, peer, payload),
        CHAT_TYPE_FILE_ACCEPT => {
            if let Ok(id) = payload.parse::<u32>() {
                app.netlog(NetLevel::Ok, "file accepted by peer");
                send_file_to_peer(app, peer, id);
            }
        }
        _ => {}
    }
}

fn receive_file_meta(app: &Arc<AppState>, peer: &PeerHandle, payload: &str) {
    let mut parts = payload.splitn(3, '|');
    let Some(id) = parts.next().and_then(|s| s.parse::<u32>().ok()) else {
        app.netlog(NetLevel::Warn, "bad file metadata");
        return;
    };
    let Some(size) = parts.next().and_then(|s| s.parse::<u64>().ok()) else {
        app.netlog(NetLevel::Warn, "bad file metadata");
        return;
    };
    let Some(name) = parts.next() else {
        app.netlog(NetLevel::Warn, "bad file metadata");
        return;
    };

    if !app.can_receive_file() {
        app.emit_error("too many incoming files");
        return;
    }

    let clean_name = sanitize_file_name(name);
    let info = peer.info.lock().unwrap();
    let sender = info.remote_pid_full.clone();
    let display_sender = sanitize_file_name(if info.remote_nick.is_empty() {
        &info.remote_pid_short
    } else {
        &info.remote_nick
    });
    drop(info);

    let path = PathBuf::from("speer_received").join(format!("{display_sender}_{id}_{clean_name}"));
    app.files.lock().unwrap().rx.push(RxFile {
        id,
        expected: size,
        received: 0,
        sender,
        name: clean_name.clone(),
        path,
        file: None,
    });

    app.emit_system(format!(
        "incoming file {clean_name} ({size} bytes). type /accept {id} to receive"
    ));
    app.netlog(NetLevel::Warn, format!("file pending {clean_name} id {id}"));
}

fn receive_file_chunk(app: &Arc<AppState>, peer: &PeerHandle, payload: &str) {
    let Some((id_s, hex)) = payload.split_once('|') else {
        app.netlog(NetLevel::Warn, "bad file chunk");
        return;
    };
    let Ok(id) = id_s.parse::<u32>() else {
        app.netlog(NetLevel::Warn, "bad file chunk");
        return;
    };
    let Ok(chunk) = hex_decode(hex) else {
        app.netlog(NetLevel::Warn, "bad file chunk hex");
        return;
    };

    let sender = peer.info.lock().unwrap().remote_pid_full.clone();
    let mut files = app.files.lock().unwrap();
    let Some(rx) = files
        .rx
        .iter_mut()
        .find(|f| f.id == id && f.sender == sender)
    else {
        app.netlog(NetLevel::Warn, format!("chunk for unknown file {id}"));
        return;
    };
    let Some(file) = rx.file.as_mut() else {
        app.netlog(
            NetLevel::Warn,
            format!("ignored unaccepted chunk {}", rx.name),
        );
        return;
    };
    if rx.received.saturating_add(chunk.len() as u64) > rx.expected {
        let name = rx.name.clone();
        let temp_path = temp_receive_path(&rx.path);
        drop(rx.file.take());
        drop(files);
        let _ = fs::remove_file(temp_path);
        app.emit_error(format!(
            "file {name} exceeded advertised size; transfer canceled"
        ));
        return;
    }
    if file.write_all(&chunk).is_ok() {
        rx.received += chunk.len() as u64;
        app.netlog(
            NetLevel::Traffic,
            format!("file rx {} {}/{}B", rx.name, rx.received, rx.expected),
        );
    } else {
        let name = rx.name.clone();
        let temp_path = temp_receive_path(&rx.path);
        drop(rx.file.take());
        drop(files);
        let _ = fs::remove_file(temp_path);
        app.emit_error(format!("could not write file {name}; transfer canceled"));
    }
}

fn receive_file_done(app: &Arc<AppState>, peer: &PeerHandle, payload: &str) {
    let Ok(id) = payload.parse::<u32>() else {
        return;
    };
    let sender = peer.info.lock().unwrap().remote_pid_full.clone();
    let mut files = app.files.lock().unwrap();
    let Some(pos) = files
        .rx
        .iter()
        .position(|f| f.id == id && f.sender == sender)
    else {
        return;
    };
    let mut rx = files.rx.remove(pos);
    drop(rx.file.take());
    let ok = rx.received == rx.expected;
    let temp_path = temp_receive_path(&rx.path);
    drop(files);

    if ok {
        match fs::rename(&temp_path, &rx.path) {
            Ok(()) => {
                app.emit_system(format!(
                    "received file {} -> {} ({}/{} bytes)",
                    rx.name,
                    rx.path.display(),
                    rx.received,
                    rx.expected
                ));
                app.netlog(NetLevel::Ok, format!("file saved {}", rx.name));
            }
            Err(err) => {
                let _ = fs::remove_file(&temp_path);
                app.emit_error(format!("could not save received file {}: {err}", rx.name));
                app.netlog(NetLevel::Error, format!("file save failed {}", rx.name));
            }
        }
    } else {
        let _ = fs::remove_file(&temp_path);
        app.emit_system(format!(
            "discarded incomplete file {} ({}/{} bytes)",
            rx.name, rx.received, rx.expected
        ));
        app.netlog(NetLevel::Warn, format!("file incomplete {}", rx.name));
    }
}

pub fn send_file_to_peer(app: &Arc<AppState>, peer: &PeerHandle, file_id: u32) {
    let tx = {
        let files = app.files.lock().unwrap();
        files.tx.iter().find(|f| f.id == file_id).cloned()
    };
    let Some(tx) = tx else {
        app.netlog(NetLevel::Warn, format!("accept for unknown file {file_id}"));
        return;
    };

    let Ok(mut file) = File::open(&tx.path) else {
        app.emit_error(format!("could not reopen {}", tx.name));
        return;
    };

    let mut sent = 0u64;
    let mut chunk = [0u8; FILE_CHUNK_BYTES];
    while let Ok(n) = file.read(&mut chunk) {
        if n == 0 {
            break;
        }
        sent += n as u64;
        peer.enqueue(
            CHAT_TYPE_FILE_CHUNK,
            format!("{}|{}", file_id, hex_encode(&chunk[..n])),
        );
        app.netlog(
            NetLevel::Traffic,
            format!("file tx {} {sent}/{}B", tx.name, tx.size),
        );
    }
    peer.enqueue(CHAT_TYPE_FILE_DONE, file_id.to_string());
    app.netlog(NetLevel::Ok, format!("file tx done {}", tx.name));
}

pub fn cmd_send_file(app: &Arc<AppState>, arg: &str) {
    let path = arg.trim().trim_matches('"');
    if path.is_empty() {
        app.emit_error("usage: /send <path>");
        return;
    }
    if app.connected_peers().is_empty() {
        app.emit_error("no connected peers for file transfer");
        return;
    }
    let path = Path::new(path);
    let Ok(meta) = fs::metadata(path) else {
        app.emit_error(format!("could not open file: {}", path.display()));
        return;
    };
    let file_id = app.next_file_id.fetch_add(1, Ordering::Relaxed) ^ unix_time_secs() as u32;
    let name = sanitize_file_name(path.file_name().and_then(|s| s.to_str()).unwrap_or("blob"));
    app.files.lock().unwrap().tx.push(TxFile {
        id: file_id,
        size: meta.len(),
        name: name.clone(),
        path: path.to_path_buf(),
    });
    app.broadcast(
        CHAT_TYPE_FILE_META,
        &format!("{file_id}|{}|{name}", meta.len()),
    );
    app.emit_system(format!(
        "offered file {name} ({} bytes), waiting for peer acceptance",
        meta.len()
    ));
    app.netlog(NetLevel::Ok, format!("file offered {name} id {file_id}"));
}

pub fn cmd_accept_file(app: &Arc<AppState>, wanted: Option<u32>) {
    if let Err(err) = fs::create_dir_all("speer_received") {
        app.emit_error(format!("could not create speer_received directory: {err}"));
        return;
    }

    let (sender, id, name, path) = {
        let mut files = app.files.lock().unwrap();
        let candidates: Vec<usize> = files
            .rx
            .iter()
            .enumerate()
            .filter(|(_, f)| f.file.is_none() && wanted.is_none_or(|id| f.id == id))
            .map(|(i, _)| i)
            .collect();
        if candidates.is_empty() || (wanted.is_none() && candidates.len() > 1) {
            app.emit_error(if wanted.is_none() && candidates.len() > 1 {
                "multiple pending files; use /accept <id>"
            } else {
                "no pending file to accept"
            });
            return;
        }
        let idx = candidates[0];
        let path = files.rx[idx].path.clone();
        let temp_path = temp_receive_path(&path);
        let Ok(file) = File::create(&temp_path) else {
            app.emit_error("could not open receive file");
            return;
        };
        files.rx[idx].file = Some(file);
        (
            files.rx[idx].sender.clone(),
            files.rx[idx].id,
            files.rx[idx].name.clone(),
            path,
        )
    };

    for peer in app.connected_peers() {
        if peer.info.lock().unwrap().remote_pid_full == sender {
            peer.enqueue(CHAT_TYPE_FILE_ACCEPT, id.to_string());
            break;
        }
    }
    app.emit_system(format!("accepted file {name} -> {}", path.display()));
    app.netlog(NetLevel::Ok, format!("file accept {name} id {id}"));
}

fn temp_receive_path(path: &Path) -> PathBuf {
    let file_name = path
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or("download");
    path.with_file_name(format!("{file_name}.part"))
}