feat: implement CLI
CI / format (push) Has been cancelled
CI / test (push) Has been cancelled
CI / packaging (push) Has been cancelled

This commit is contained in:
Tien Do Nam
2026-07-29 00:37:00 +02:00
parent 275b399e7c
commit 0aea05ac85
47 changed files with 8227 additions and 672 deletions
+1
View File
@@ -0,0 +1 @@
target/
+4768
View File
File diff suppressed because it is too large Load Diff
+27 -2
View File
@@ -1,6 +1,31 @@
[package]
name = "cli"
version = "0.1.0"
name = "localsend-cli"
version = "1.17.0"
edition = "2024"
[[bin]]
name = "localsend-cli"
path = "src/main.rs"
[dependencies]
localsend = { path = "../packages/core", features = ["full"] }
anyhow = "1.0"
bytes = "1.11"
clap = { version = "4.6", features = ["derive", "env"] }
crossterm = "0.29"
dirs = "6.0"
futures-util = "0.3"
gethostname = "1.0"
if-addrs = "0.15"
mime_guess = "2.0"
pem = "4.0"
ratatui = "0.30"
ratatui-explorer = "0.3"
rcgen = "0.14"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["full"] }
tokio-stream = "0.1"
tokio-util = { version = "0.7", features = ["rt"] }
toml = "1.1"
uuid = { version = "1", features = ["v4"] }
+91
View File
@@ -0,0 +1,91 @@
//! Device discovery: multicast announcements are answered with an HTTP
//! register request, and confirmed devices get a slot in the registry.
use super::{App, AppEvent};
use crate::ui::Category;
use localsend::http::client::v2::LsHttpClientV2;
use localsend::http::dto::ProtocolType;
use localsend::http::dto_v2::ProtocolTypeV2;
use localsend::multicast::MulticastEvent;
use std::time::Duration;
impl App {
pub(super) fn handle_multicast(&mut self, event: MulticastEvent) {
let MulticastEvent::Discovered {
ip,
scope_id,
message,
} = event;
if message.fingerprint == self.storage.identity.fingerprint {
return;
}
let host = match scope_id {
Some(scope_id) => format!("{ip}%{scope_id}"),
None => ip.to_string(),
};
// Answer the announcement with an HTTP register request; the device
// is only shown once that request succeeds.
let identity = self.storage.identity.clone();
let events_tx = self.events_tx.clone();
tokio::spawn(async move {
let expected_fingerprint = match message.protocol {
ProtocolTypeV2::Https => Some(message.fingerprint.clone()),
ProtocolTypeV2::Http => None,
};
let Ok(client) = LsHttpClientV2::try_new(
&identity.key_pem,
&identity.cert_pem,
expected_fingerprint,
Some(Duration::from_secs(5)),
) else {
return;
};
let protocol = match message.protocol {
ProtocolTypeV2::Http => ProtocolType::Http,
ProtocolTypeV2::Https => ProtocolType::Https,
};
let result = client
.register(protocol, &host, message.port, identity.register_dto())
.await;
if let Ok(response) = result {
let _ = events_tx
.send(AppEvent::DeviceUp {
alias: response.body.alias,
host,
port: message.port,
protocol: message.protocol,
fingerprint: message.fingerprint,
})
.await;
}
});
}
pub(super) fn device_up(
&mut self,
alias: String,
host: String,
port: u16,
protocol: ProtocolTypeV2,
fingerprint: String,
) {
if fingerprint == self.storage.identity.fingerprint {
return;
}
if let Some(device) = self
.registry
.upsert(alias, host, port, protocol, fingerprint)
{
self.ui.log(
Category::Discovery,
&format!(
"[{}] {} ({})",
device.slot_label(),
device.alias,
device.host
),
);
}
}
}
+294
View File
@@ -0,0 +1,294 @@
mod discovery;
mod receive;
mod sending;
mod status;
use crate::Args;
use crate::devices::DeviceRegistry;
use crate::picker::Picker;
use crate::storage;
use crate::ui::{Category, Ui};
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use localsend::http::dto_v2::ProtocolTypeV2;
use localsend::http::server::v2::ServerEventV2;
use localsend::http::server::{ServerConfigV2, ServerHandle, start_with_port};
use localsend::multicast::{
self, DEFAULT_MULTICAST_GROUP, DEFAULT_MULTICAST_GROUP_V6, DEFAULT_PORT, InterfaceFilter,
MulticastConfig, MulticastEvent,
};
use receive::{Answer, PendingReceive, ReceiveSession};
use sending::SendState;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{mpsc, oneshot};
/// Events processed by the central application loop.
pub enum AppEvent {
/// A key was pressed.
Key(KeyEvent),
/// A device was confirmed reachable (it registered with us, or it
/// announced itself and answered our register request).
DeviceUp {
alias: String,
host: String,
port: u16,
protocol: ProtocolTypeV2,
fingerprint: String,
},
/// A file of the active receive session finished (or failed).
ReceiveFileResult {
session_id: String,
file_id: String,
result: Result<(), String>,
},
/// The send task created its upload session.
SendSessionStarted {
session_id: String,
accepted_bytes: u64,
},
/// The send task ended (successfully or not).
SendEnded,
/// A log line produced by a background task.
Log { category: Category, text: String },
}
struct App {
ui: Ui,
server: Arc<ServerHandle>,
registry: DeviceRegistry,
/// Config, identity and paired devices, see [`storage::Repository`].
storage: storage::Repository,
pending: Option<PendingReceive>,
receive: Option<ReceiveSession>,
send: Option<SendState>,
picker: Option<Picker>,
events_tx: mpsc::Sender<AppEvent>,
}
pub async fn run(args: Args) -> anyhow::Result<()> {
let storage = storage::Repository::load(&args)?;
let identity = storage.identity.clone();
let (events_tx, mut events_rx) = mpsc::channel::<AppEvent>(64);
// HTTP server (always TLS, like the app).
let (server_tx, mut server_rx) = mpsc::channel::<ServerEventV2>(16);
let (server_stop_tx, server_stop_rx) = oneshot::channel::<()>();
let server = start_with_port(
identity.port,
Some(identity.tls_config()),
identity.client_info(),
None,
Some(ServerConfigV2 {
pin: None,
event_tx: server_tx,
}),
None,
server_stop_rx,
)
.await?;
let server = Arc::new(server);
// Multicast discovery. Failure is not fatal: transfers to this device
// still work for peers that know its address.
let (multicast_tx, multicast_rx) = mpsc::channel::<MulticastEvent>(16);
let (multicast_stop_tx, multicast_stop_rx) = oneshot::channel::<()>();
let multicast = multicast::start(
MulticastConfig {
group: DEFAULT_MULTICAST_GROUP,
group_v6: Some(DEFAULT_MULTICAST_GROUP_V6),
port: DEFAULT_PORT,
interface_filter: InterfaceFilter::default(),
device: identity.multicast_device(),
event_tx: multicast_tx,
},
multicast_stop_rx,
)
.await;
let multicast = match multicast {
Ok(handle) => Some(Arc::new(handle)),
Err(err) => {
eprintln!("Multicast discovery unavailable: {err:#}");
None
}
};
let mut multicast_rx = multicast.is_some().then_some(multicast_rx);
if let Some(handle) = &multicast {
// Announce this device; peers answer with an HTTP register request.
let handle = handle.clone();
tokio::spawn(async move { handle.announce().await });
}
crossterm::terminal::enable_raw_mode()?;
// Keyboard reader. The blocking thread ends with the process.
std::thread::spawn({
let events_tx = events_tx.clone();
move || {
loop {
match crossterm::event::read() {
Ok(Event::Key(key)) if key.kind == KeyEventKind::Press => {
if events_tx.blocking_send(AppEvent::Key(key)).is_err() {
return;
}
}
Ok(_) => {}
Err(_) => return,
}
}
}
});
let mut app = App {
ui: Ui::new(),
server: server.clone(),
registry: DeviceRegistry::new(),
storage,
pending: None,
receive: None,
send: None,
picker: None,
events_tx: events_tx.clone(),
};
app.ui.log_plain(&crate::banner::render(&app.storage));
let mut tick = tokio::time::interval(Duration::from_millis(250));
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut quit = false;
while !quit {
tokio::select! {
Some(event) = events_rx.recv() => {
quit = app.handle_event(event).await;
}
Some(event) = server_rx.recv() => {
app.handle_server_event(event);
}
Some(event) = recv_opt(&mut multicast_rx) => {
app.handle_multicast(event);
}
_ = tick.tick() => {
app.tick();
}
}
}
// Shutdown: leave a possibly open picker, restore the terminal, stop the
// network tasks (briefly, so the ports are released cleanly).
if let Some(picker) = app.picker.take() {
picker.close();
app.ui.resume();
}
app.ui.set_status(None);
let _ = crossterm::terminal::disable_raw_mode();
let _ = server_stop_tx.send(());
let _ = multicast_stop_tx.send(());
let _ = tokio::time::timeout(Duration::from_secs(1), server.wait_stopped()).await;
if let Some(multicast) = &multicast {
let _ = tokio::time::timeout(Duration::from_secs(1), multicast.wait_stopped()).await;
}
println!("Bye!");
Ok(())
}
/// Receives from an optional channel, pending forever when there is none.
async fn recv_opt<T>(rx: &mut Option<mpsc::Receiver<T>>) -> Option<T> {
match rx {
Some(rx) => rx.recv().await,
None => std::future::pending().await,
}
}
impl App {
/// Handles an event; returns `true` when the application should quit.
async fn handle_event(&mut self, event: AppEvent) -> bool {
match event {
AppEvent::Key(key) => return self.handle_key(key),
AppEvent::DeviceUp {
alias,
host,
port,
protocol,
fingerprint,
} => self.device_up(alias, host, port, protocol, fingerprint),
AppEvent::ReceiveFileResult {
session_id,
file_id,
result,
} => self.receive_file_result(session_id, file_id, result),
AppEvent::SendSessionStarted {
session_id,
accepted_bytes,
} => {
if let Some(send) = &mut self.send {
send.session_id = Some(session_id);
send.total_bytes = accepted_bytes;
}
}
AppEvent::SendEnded => {
self.send = None;
self.render_status();
}
AppEvent::Log { category, text } => self.ui.log(category, &text),
}
false
}
fn handle_key(&mut self, key: KeyEvent) -> bool {
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return self.handle_ctrl_c();
}
// While the picker is open it consumes every key.
if self.picker.is_some() {
self.handle_picker_key(key);
return false;
}
if let KeyCode::Char(c) = key.code {
match c.to_ascii_lowercase() {
'y' => self.answer_pending(Answer::Accept),
'n' => self.answer_pending(Answer::Decline),
'p' => self.answer_pending(Answer::AcceptAndPair),
'1'..='9' => self.start_picking(c as u8 - b'0'),
_ => {}
}
}
false
}
/// Cancels the current activity: the picker, the pending request and the
/// active transfers. Returns `true` (quit) only when there was nothing to
/// cancel.
fn handle_ctrl_c(&mut self) -> bool {
if let Some(picker) = self.picker.take() {
picker.close();
self.ui.resume();
return false;
}
let mut cancelled = false;
if self.pending.is_some() {
self.answer_pending(Answer::Decline);
cancelled = true;
}
if let Some(send) = &self.send {
// The send task notices the token, notifies the receiver and
// reports back via [AppEvent::SendEnded].
send.cancel.token.cancel();
cancelled = true;
}
if self.receive.is_some() {
self.cancel_receive();
cancelled = true;
}
!cancelled
}
}
+452
View File
@@ -0,0 +1,452 @@
//! The receiving side: incoming transfer requests, the Y/N/P decision and the
//! active receive session.
use super::App;
use crate::ui::Category;
use crate::util::{self, SpeedMeter};
use localsend::http::client::v2::LsHttpClientV2;
use localsend::http::dto::ProtocolType;
use localsend::http::dto_v2::ProtocolTypeV2;
use localsend::http::server::common::save::FileUploadTarget;
use localsend::http::server::v2::{PrepareUploadDecisionV2, ServerEventV2, SessionEndReasonV2};
use localsend::model::transfer::FileDto;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use tokio::sync::{mpsc, oneshot};
/// Where to reach the device a receive session originates from, so a
/// receiver-side cancel can be delivered back to it over HTTP.
#[derive(Clone)]
pub(super) struct SenderTarget {
pub(super) host: String,
pub(super) port: u16,
pub(super) protocol: ProtocolTypeV2,
pub(super) fingerprint: String,
}
/// An incoming transfer request waiting for the user's Y/N/P decision.
pub(super) struct PendingReceive {
pub(super) session_id: String,
pub(super) alias: String,
pub(super) sender: SenderTarget,
pub(super) files: HashMap<String, FileDto>,
pub(super) decision_tx: oneshot::Sender<PrepareUploadDecisionV2>,
}
/// An accepted upload session that is being received.
pub(super) struct ReceiveSession {
pub(super) session_id: String,
pub(super) alias: String,
pub(super) sender: SenderTarget,
pub(super) files: HashMap<String, FileDto>,
pub(super) total_bytes: u64,
pub(super) finished_files: usize,
pub(super) failed_files: usize,
pub(super) finalized_bytes: u64,
pub(super) in_progress: HashMap<String, Arc<AtomicU64>>,
pub(super) started: Instant,
pub(super) speed: SpeedMeter,
/// Set when the server reported the end of the session. The summary is
/// deferred until the in-flight per-file results have all arrived.
pub(super) ended: Option<SessionEndReasonV2>,
}
impl ReceiveSession {
fn new(
session_id: String,
alias: String,
sender: SenderTarget,
files: HashMap<String, FileDto>,
) -> Self {
let total_bytes = files.values().map(|file| file.size).sum();
Self {
session_id,
alias,
sender,
files,
total_bytes,
finished_files: 0,
failed_files: 0,
finalized_bytes: 0,
in_progress: HashMap::new(),
started: Instant::now(),
speed: SpeedMeter::new(),
ended: None,
}
}
pub(super) fn done_bytes(&self) -> u64 {
self.finalized_bytes
+ self
.in_progress
.values()
.map(|progress| progress.load(Ordering::Relaxed))
.sum::<u64>()
}
}
/// The user's decision on a [`PendingReceive`].
pub(super) enum Answer {
Accept,
Decline,
AcceptAndPair,
}
impl App {
pub(super) fn handle_server_event(&mut self, event: ServerEventV2) {
match event {
ServerEventV2::Register { ip, info } => {
self.device_up(
info.alias,
ip.to_string(),
info.port,
info.protocol,
info.fingerprint,
);
}
ServerEventV2::PrepareUpload {
session_id,
ip,
info,
cert_fingerprint,
files,
decision_tx,
} => {
// The sender is clearly reachable; make sure it has a slot.
self.device_up(
info.alias.clone(),
ip.to_string(),
info.port,
info.protocol,
info.fingerprint.clone(),
);
let sender = SenderTarget {
host: ip.to_string(),
port: info.port,
protocol: info.protocol,
fingerprint: cert_fingerprint.unwrap_or_else(|| info.fingerprint.clone()),
};
if self.storage.paired.contains(&sender.fingerprint) {
let ids: HashSet<String> = files.keys().cloned().collect();
if decision_tx
.send(PrepareUploadDecisionV2::Accept(ids))
.is_ok()
{
// Nothing to confirm: the progress bar and the summary
// are the whole story for an auto-accepted request.
self.receive =
Some(ReceiveSession::new(session_id, info.alias, sender, files));
}
} else {
let total: u64 = files.values().map(|file| file.size).sum();
let mut lines = vec![info.alias.clone(), "\nFiles:".to_string()];
let mut sorted: Vec<&FileDto> = files.values().collect();
sorted.sort_by_key(|file| &file.file_name);
for file in sorted {
lines.push(format!(
" {} ({})",
file.file_name,
util::format_bytes(file.size)
));
}
lines.push(format!("Total size: {}", util::format_bytes(total)));
lines.push("\nAccept? Y/N/P (P = accept and pair)".to_string());
self.ui.log(Category::Receive, &lines.join("\n"));
self.pending = Some(PendingReceive {
session_id,
alias: info.alias,
sender,
files,
decision_tx,
});
}
}
ServerEventV2::FileUpload {
session_id,
file_id,
file,
target_tx,
} => self.handle_file_upload(session_id, file_id, file, target_tx),
ServerEventV2::SessionEnd { session_id, reason } => {
let Some(session) = self
.receive
.as_mut()
.filter(|session| session.session_id == session_id)
else {
return;
};
// The per-file results race with this event (they arrive on
// the app's own channel); print the summary only once the
// last in-flight file has reported its outcome.
session.ended = Some(reason);
self.finish_receive_if_done();
}
ServerEventV2::PrepareUploadAborted { session_id } => {
if let Some(pending) = &self.pending
&& pending.session_id == session_id
{
let pending = self.pending.take().unwrap();
self.ui.log(
Category::Receive,
&format!("{}: Aborted by sender", pending.alias),
);
}
}
ServerEventV2::CancelReceived { ip, session_id } => {
if let Some(send) = &self.send
&& send.session_id.as_deref() == Some(session_id.as_str())
&& send.host == ip.to_string()
{
send.cancel.by_peer.store(true, Ordering::Relaxed);
send.cancel.token.cancel();
}
}
}
}
fn handle_file_upload(
&mut self,
session_id: String,
file_id: String,
file: FileDto,
target_tx: oneshot::Sender<FileUploadTarget>,
) {
let Some(session) = self
.receive
.as_mut()
.filter(|session| session.session_id == session_id)
else {
// Unknown session: dropping the responder fails the request.
return;
};
let path = util::unique_path(&self.storage.destination, &file.file_name);
let progress = Arc::new(AtomicU64::new(0));
session
.in_progress
.insert(file_id.clone(), progress.clone());
let (progress_tx, mut progress_rx) = mpsc::channel::<u64>(16);
tokio::spawn(async move {
while let Some(written) = progress_rx.recv().await {
progress.store(written, Ordering::Relaxed);
}
});
let (result_tx, result_rx) = oneshot::channel::<Result<(), String>>();
tokio::spawn({
let events_tx = self.events_tx.clone();
async move {
let result = match result_rx.await {
Ok(result) => result,
Err(_) => Err("Upload aborted".to_string()),
};
let _ = events_tx
.send(super::AppEvent::ReceiveFileResult {
session_id,
file_id,
result,
})
.await;
}
});
let _ = target_tx.send(FileUploadTarget::Path {
path,
result_tx,
progress_tx: Some(progress_tx),
});
}
pub(super) fn receive_file_result(
&mut self,
session_id: String,
file_id: String,
result: Result<(), String>,
) {
let Some(session) = self
.receive
.as_mut()
.filter(|session| session.session_id == session_id)
else {
return;
};
session.in_progress.remove(&file_id);
match result {
Ok(()) => {
session.finished_files += 1;
session.finalized_bytes += session
.files
.get(&file_id)
.map(|file| file.size)
.unwrap_or(0);
}
Err(err) => {
session.failed_files += 1;
let name = session
.files
.get(&file_id)
.map(|file| file.file_name.clone())
.unwrap_or(file_id);
let alias = session.alias.clone();
self.ui.log(
Category::Receive,
&format!("{alias}: failed to receive {name}: {err}"),
);
}
}
self.finish_receive_if_done();
}
/// Prints the session summary and clears the session once the server
/// reported its end and no per-file result is outstanding.
fn finish_receive_if_done(&mut self) {
let done = self
.receive
.as_ref()
.is_some_and(|session| session.ended.is_some() && session.in_progress.is_empty());
if !done {
return;
}
let session = self.receive.take().unwrap();
match session.ended.unwrap() {
SessionEndReasonV2::Finished => {
let mut text = format!(
"{}: Received {} file{} ({}, took {})",
session.alias,
session.finished_files,
if session.finished_files == 1 { "" } else { "s" },
util::format_bytes(session.finalized_bytes),
util::format_duration(session.started.elapsed()),
);
if session.failed_files > 0 {
text.push_str(&format!(", {} failed", session.failed_files));
}
self.ui.log(Category::Receive, &text);
}
SessionEndReasonV2::Cancelled => {
self.ui.log(
Category::Receive,
&format!(
"{}: cancelled by sender ({} of {} files received)",
session.alias,
session.finished_files,
session.files.len(),
),
);
}
}
self.render_status();
}
/// Cancels the active receive session: rejects further uploads on the
/// server, notifies the sender and prints the summary. Late per-file
/// results are dropped by the session-id filters.
pub(super) fn cancel_receive(&mut self) {
let Some(session) = self.receive.take() else {
return;
};
let server = self.server.clone();
let identity = self.storage.identity.clone();
let sender = session.sender.clone();
let session_id = session.session_id.clone();
tokio::spawn(async move {
server.cancel_v2_session(&session_id).await;
// Best effort: without it the sender only notices through its
// failing upload requests.
let expected_fingerprint = match sender.protocol {
ProtocolTypeV2::Https => Some(sender.fingerprint.clone()),
ProtocolTypeV2::Http => None,
};
let Ok(client) = LsHttpClientV2::try_new(
&identity.key_pem,
&identity.cert_pem,
expected_fingerprint,
Some(Duration::from_secs(5)),
) else {
return;
};
let protocol = match sender.protocol {
ProtocolTypeV2::Http => ProtocolType::Http,
ProtocolTypeV2::Https => ProtocolType::Https,
};
let _ = client
.cancel(protocol, &sender.host, sender.port, &session_id)
.await;
});
self.ui.log(
Category::Receive,
&format!(
"{}: cancelled ({} of {} files received)",
session.alias,
session.finished_files,
session.files.len(),
),
);
self.render_status();
}
pub(super) fn answer_pending(&mut self, answer: Answer) {
let Some(pending) = self.pending.take() else {
return;
};
match answer {
Answer::Decline => {
let _ = pending.decision_tx.send(PrepareUploadDecisionV2::Decline);
self.ui
.log(Category::Receive, &format!("{}: Declined", pending.alias));
}
Answer::Accept | Answer::AcceptAndPair => {
self.ui
.log(Category::Receive, &format!("{}: Accepted", pending.alias));
if matches!(answer, Answer::AcceptAndPair) {
match self
.storage
.paired
.insert(pending.sender.fingerprint.clone(), pending.alias.clone())
{
Ok(()) => self.ui.log(
Category::Receive,
&format!(
"{}: Paired. Future requests are auto-accepted.",
pending.alias
),
),
Err(err) => self.ui.log(
Category::Receive,
&format!(
"{}: Paired for this run, but saving failed: {err:#}",
pending.alias
),
),
}
}
let ids: HashSet<String> = pending.files.keys().cloned().collect();
if pending
.decision_tx
.send(PrepareUploadDecisionV2::Accept(ids))
.is_err()
{
self.ui.log(
Category::Receive,
&format!("{}: request already ended", pending.alias),
);
return;
}
self.receive = Some(ReceiveSession::new(
pending.session_id,
pending.alias,
pending.sender,
pending.files,
));
}
}
}
}
+143
View File
@@ -0,0 +1,143 @@
//! The sending side: picking a target device and files, and tracking the
//! transfer driven by the [`crate::send_task`].
use super::App;
use crate::picker::{Picker, PickerOutcome};
use crate::send_task;
use crate::ui::Category;
use crate::util::SpeedMeter;
use crossterm::event::KeyEvent;
use localsend::model::transfer::FileDto;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicU64;
use uuid::Uuid;
/// An outgoing transfer driven by the send task.
pub(super) struct SendState {
pub(super) session_id: Option<String>,
pub(super) alias: String,
pub(super) host: String,
pub(super) total_bytes: u64,
pub(super) sent: Arc<AtomicU64>,
pub(super) cancel: send_task::SendCancel,
pub(super) speed: SpeedMeter,
}
impl App {
pub(super) fn start_picking(&mut self, slot: u8) {
let Some(device) = self.registry.by_slot(slot) else {
self.ui
.log(Category::Send, &format!("No device on [{slot}]"));
return;
};
if self.send.is_some() {
self.ui.log(Category::Send, "A send is already in progress");
return;
}
let alias = device.alias.clone();
match Picker::open(slot) {
Ok(picker) => {
self.ui.suspend();
self.picker = Some(picker);
}
Err(err) => {
self.ui.log(
Category::Send,
&format!("{alias}: could not open the file picker: {err}"),
);
}
}
}
pub(super) fn handle_picker_key(&mut self, key: KeyEvent) {
let Some(picker) = &mut self.picker else {
return;
};
match picker.handle_key(key) {
PickerOutcome::Open => {}
PickerOutcome::Picked(files) => {
let picker = self.picker.take().unwrap();
let slot = picker.slot;
picker.close();
self.ui.resume();
self.start_send(slot, files);
}
PickerOutcome::Cancelled => {
let picker = self.picker.take().unwrap();
picker.close();
self.ui.resume();
}
}
}
fn start_send(&mut self, slot: u8, picked: Vec<PathBuf>) {
let Some(device) = self.registry.by_slot(slot).cloned() else {
return;
};
let mut files = HashMap::new();
let mut paths = HashMap::new();
let mut total_bytes = 0u64;
for path in picked {
let metadata = match std::fs::metadata(&path) {
Ok(metadata) if metadata.is_file() => metadata,
_ => {
self.ui.log(
Category::Send,
&format!("Skipping unreadable file: {}", path.display()),
);
continue;
}
};
let id = Uuid::new_v4().to_string();
let file_name = path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| "unnamed".to_string());
total_bytes += metadata.len();
files.insert(
id.clone(),
FileDto {
id: id.clone(),
file_name,
size: metadata.len(),
file_type: mime_guess::from_path(&path)
.first_or_octet_stream()
.to_string(),
sha256: None,
preview: None,
metadata: None,
},
);
paths.insert(id, path);
}
if files.is_empty() {
self.ui.log(Category::Send, "No files selected");
return;
}
let progress = Arc::new(AtomicU64::new(0));
let cancel = send_task::SendCancel::new();
self.send = Some(SendState {
session_id: None,
alias: device.alias.clone(),
host: device.host.clone(),
total_bytes,
sent: progress.clone(),
cancel: cancel.clone(),
speed: SpeedMeter::new(),
});
tokio::spawn(send_task::run_send(
self.storage.identity.clone(),
device,
files,
paths,
progress,
cancel,
self.events_tx.clone(),
));
}
}
+97
View File
@@ -0,0 +1,97 @@
//! The status line at the bottom of the screen: one progress bar per active
//! transfer, refreshed by the tick of the main loop.
use super::App;
use crate::ui::Category;
use crate::util;
use std::sync::atomic::Ordering;
use std::time::Duration;
impl App {
pub(super) fn tick(&mut self) {
if let Some(picker) = &mut self.picker {
// Covers terminal resizes; key presses redraw on their own.
picker.draw();
return;
}
self.render_status();
}
pub(super) fn render_status(&mut self) {
const SEPARATOR: &str = " | ";
let mut transfers = Vec::new();
if let Some(session) = &mut self.receive {
let done = session.done_bytes();
let speed = session.speed.update(done);
transfers.push((
Category::Receive,
session.alias.clone(),
done,
session.total_bytes,
speed,
));
}
if let Some(send) = &mut self.send {
let done = send.sent.load(Ordering::Relaxed);
let speed = send.speed.update(done);
transfers.push((
Category::Send,
send.alias.clone(),
done,
send.total_bytes,
speed,
));
}
if transfers.is_empty() {
self.ui.set_status(None);
return;
}
// Split the terminal width among the transfers so the status line
// (mostly the flexible progress bars) spans the whole screen.
let width = util::terminal_width();
let part_width =
width.saturating_sub(1 + SEPARATOR.len() * (transfers.len() - 1)) / transfers.len();
let parts: Vec<String> = transfers
.iter()
.map(|(category, alias, done, total, speed)| {
transfer_status(*category, alias, *done, *total, *speed, part_width)
})
.collect();
self.ui.set_status(Some(parts.join(SEPARATOR)));
}
}
/// Formats one transfer for the status line, sizing the progress bar so the
/// whole entry occupies `width` visible columns.
fn transfer_status(
category: Category,
alias: &str,
done: u64,
total: u64,
speed: f64,
width: usize,
) -> String {
let fraction = match total {
0 => 1.0,
total => done as f64 / total as f64,
};
let eta = match speed > 1.0 && done < total {
true => util::format_duration(Duration::from_secs(((total - done) as f64 / speed) as u64)),
false => "--".to_string(),
};
let tail = format!(
" {} / {} [{}] [ETA: {eta}]",
util::format_bytes(done),
util::format_bytes(total),
util::format_speed(speed),
);
// Visible columns besides the bar: "T alias [" + "]" + tail, tag = 1 column.
let bar_width = width
.saturating_sub(alias.chars().count() + 5 + tail.chars().count())
.max(10);
format!(
"{} {alias} [{}]{tail}",
category.colored_tag(),
util::progress_bar(fraction, bar_width),
)
}
+49
View File
@@ -0,0 +1,49 @@
use crate::storage::Repository;
use crate::util;
use crossterm::style::Stylize;
#[rustfmt::skip]
const LOGO: [&str; 4] = [
" ▄▀ ▀ ▀▄ ",
"▄ ▄███▄ ▄",
"▀ ▀███▀ ▀",
" ▀▄ ▄ ▄▀ ",
];
pub fn render(storage: &Repository) -> String {
let logo = LOGO
.iter()
.enumerate()
.map(|(i, line)| {
let right = match i {
1 => " LocalSend CLI",
2 => concat!(" v", env!("CARGO_PKG_VERSION")),
_ => "",
};
format!("{}{right}", line.green())
})
.collect::<Vec<_>>()
.join("\n");
let listening = match util::local_ipv4_addresses() {
addresses if addresses.is_empty() => " - (no network interface found)".to_string(),
addresses => addresses
.iter()
.map(|address| format!(" - https://{address}:{}", storage.identity.port))
.collect::<Vec<_>>()
.join("\n"),
};
format!(
"{logo}\n\n{} {}\n{} {}\n{} {}\n{} {}\n{}\n{listening}\n\nReady to accept requests.\n\n",
"Alias:".green(),
storage.identity.alias,
"Port:".green(),
storage.identity.port,
"Destination:".green(),
storage.destination.display(),
"Config:".green(),
storage.dir.display(),
"Listening on:".green(),
)
}
+79
View File
@@ -0,0 +1,79 @@
use localsend::http::dto_v2::ProtocolTypeV2;
/// A discovered LocalSend device.
#[derive(Clone)]
pub struct Device {
/// The hotkey (1-9) assigned to this device, if one was free.
pub slot: Option<u8>,
pub alias: String,
/// The host to dial the device at: an IP address, or the scoped form
/// `fe80::1%3` for link-local IPv6 (the HTTP client accepts both).
pub host: String,
pub port: u16,
pub protocol: ProtocolTypeV2,
pub fingerprint: String,
}
impl Device {
pub fn slot_label(&self) -> String {
match self.slot {
Some(slot) => slot.to_string(),
None => "-".to_string(),
}
}
}
/// All devices seen in this run, identified by fingerprint.
pub struct DeviceRegistry {
devices: Vec<Device>,
}
impl DeviceRegistry {
pub fn new() -> Self {
Self {
devices: Vec::new(),
}
}
/// Adds or updates a device. Returns the device only when it is new
/// (i.e. should be logged): a known device is updated silently, because
/// multi-homed peers re-announce with a different address all the time.
pub fn upsert(
&mut self,
alias: String,
host: String,
port: u16,
protocol: ProtocolTypeV2,
fingerprint: String,
) -> Option<Device> {
if let Some(device) = self
.devices
.iter_mut()
.find(|device| device.fingerprint == fingerprint)
{
device.alias = alias;
device.host = host;
device.port = port;
device.protocol = protocol;
return None;
}
let slot =
(1..=9u8).find(|slot| !self.devices.iter().any(|device| device.slot == Some(*slot)));
let device = Device {
slot,
alias,
host,
port,
protocol,
fingerprint,
};
self.devices.push(device.clone());
Some(device)
}
pub fn by_slot(&self, slot: u8) -> Option<&Device> {
self.devices.iter().find(|device| device.slot == Some(slot))
}
}
+43 -2
View File
@@ -1,3 +1,44 @@
fn main() {
println!("Hello, world!");
mod app;
mod banner;
mod devices;
mod picker;
mod send_task;
mod storage;
mod ui;
mod util;
use clap::Parser;
use std::path::PathBuf;
/// LocalSend CLI
#[derive(Parser)]
#[command(name = "localsend-cli", version, about, after_help = HELP_SECTIONS)]
pub struct Args {
/// Device name shown to other devices [default: config.toml, else the hostname]
#[arg(long, env = "LOCALSEND_ALIAS")]
pub alias: Option<String>,
/// Port of the HTTP server [default: config.toml, else 53317]
#[arg(long, env = "LOCALSEND_PORT")]
pub port: Option<u16>,
/// Directory where received files are saved [default: config.toml, else the Downloads folder]
#[arg(long, env = "LOCALSEND_DESTINATION")]
pub destination: Option<PathBuf>,
}
const HELP_SECTIONS: &str = "Events:\n \
D Discovered a new device\n \
S Send files\n \
R Receive files\n\
\nHotkeys:\n \
1-9 Send files to the device with that number\n \
Y/N/P Accept / Decline / Accept-and-Pair an incoming request\n \
Ctrl+C Cancel the current transfer or request, or quit when idle\n \
\nEnvironment Variables:\n \
XDG_CONFIG_HOME, LOCALSEND_ALIAS, LOCALSEND_PORT, LOCALSEND_DESTINATION";
fn main() -> anyhow::Result<()> {
let args = Args::parse();
tokio::runtime::Runtime::new()?.block_on(app::run(args))
}
+322
View File
@@ -0,0 +1,322 @@
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
use crossterm::terminal::{Clear, ClearType};
use crossterm::{cursor, execute};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Color, Style};
use ratatui::text::Span;
use ratatui::widgets::{Block, Borders, HighlightSpacing, List, ListState, Paragraph};
use ratatui_explorer::{File, FileExplorer};
use std::io::Stdout;
use std::path::PathBuf;
/// How far `PageUp` / `PageDown` jump, matching `ratatui_explorer`.
const SCROLL_COUNT: usize = 12;
/// A modal, terminal-native file picker rendered on the alternate screen
/// while the log UI is suspended.
///
/// Typing filters the listing, Space toggles files on and off, Enter confirms
/// (or descends into the highlighted directory), Esc clears the filter or
/// cancels. Confirming with an empty selection picks the highlighted file.
///
/// The explorer only supplies the directory listing; navigation and rendering
/// are done here so that the filter can hide entries.
pub struct Picker {
/// The device slot the picked files will be sent to.
pub slot: u8,
explorer: FileExplorer,
terminal: Terminal<CrosstermBackend<Stdout>>,
selected: Vec<PathBuf>,
/// The search query, matched as a case-insensitive subsequence against the
/// entry names. Empty means "show everything".
query: String,
/// Indices into `explorer.files()` that match `query`, in listing order.
matches: Vec<usize>,
list_state: ListState,
}
/// What a key press did to the picker.
pub enum PickerOutcome {
/// The picker stays open.
Open,
/// The user confirmed the selection.
Picked(Vec<PathBuf>),
/// The user cancelled.
Cancelled,
}
impl Picker {
/// Enters the alternate screen and shows the picker.
/// The caller must suspend the log UI first and resume it after [Picker::close].
pub fn open(slot: u8) -> anyhow::Result<Self> {
let explorer = FileExplorer::new()?;
execute!(
std::io::stdout(),
crossterm::terminal::EnterAlternateScreen,
cursor::Hide
)?;
let terminal = Terminal::new(CrosstermBackend::new(std::io::stdout()))?;
let mut picker = Self {
slot,
explorer,
terminal,
selected: Vec::new(),
query: String::new(),
matches: Vec::new(),
list_state: ListState::default(),
};
picker.refresh_matches();
picker.select_first_entry();
picker.draw();
Ok(picker)
}
/// Leaves the alternate screen. Must be called exactly once.
pub fn close(self) {
// Clears via crossterm, not `Terminal::clear`: the latter queries the
// cursor position, whose response is read from the event stream but
// the keyboard reader thread is parked in `crossterm::event::read()`,
// so the query only ever returns after crossterm's 2s timeout.
let _ = execute!(
std::io::stdout(),
Clear(ClearType::All),
cursor::MoveTo(0, 0),
crossterm::terminal::LeaveAlternateScreen,
cursor::Show
);
}
pub fn handle_key(&mut self, key: KeyEvent) -> PickerOutcome {
match key.code {
KeyCode::Esc => {
if self.query.is_empty() {
return PickerOutcome::Cancelled;
}
self.query.clear();
self.refresh_matches();
}
KeyCode::Up => self.move_cursor(-1, true),
KeyCode::Down => self.move_cursor(1, true),
KeyCode::PageUp => self.move_cursor(-(SCROLL_COUNT as isize), false),
KeyCode::PageDown => self.move_cursor(SCROLL_COUNT as isize, false),
KeyCode::Home => self.select_match(0),
KeyCode::End => self.select_match(self.matches.len().saturating_sub(1)),
KeyCode::Left => self.navigate(key),
KeyCode::Right => {
// Never act on a hidden entry: with no matches the explorer's
// index still points at whatever was highlighted before.
if self.current().is_some_and(|file| file.is_dir) {
self.navigate(key);
}
}
KeyCode::Backspace => {
// Purely an editing key: erasing one character too many must not
// leave the directory. Left does that, and so does the `../` entry.
if self.query.pop().is_some() {
self.refresh_matches();
}
}
KeyCode::Char(' ') => {
if let Some(current) = self.current()
&& !current.is_dir
{
let path = current.path.clone();
match self.selected.iter().position(|p| *p == path) {
Some(index) => {
self.selected.remove(index);
}
None => self.selected.push(path),
}
}
}
KeyCode::Enter => match self
.current()
.map(|current| (current.is_dir, current.path.clone()))
{
Some((true, _)) => self.navigate(key),
Some((false, path)) => {
let mut files = std::mem::take(&mut self.selected);
if files.is_empty() {
files.push(path);
}
return PickerOutcome::Picked(files);
}
None => {
let files = std::mem::take(&mut self.selected);
if !files.is_empty() {
return PickerOutcome::Picked(files);
}
}
},
KeyCode::Char(c)
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
self.query.push(c);
self.refresh_matches();
}
_ => {}
}
self.draw();
PickerOutcome::Open
}
/// The highlighted entry, or `None` while nothing matches the query.
fn current(&self) -> Option<&File> {
self.cursor().map(|_| self.explorer.current())
}
/// Position of the highlighted entry within [Picker::matches].
fn cursor(&self) -> Option<usize> {
self.matches
.iter()
.position(|&index| index == self.explorer.selected_idx())
}
/// Changes the directory via the explorer and drops the query, since it was
/// only ever meant for the listing that is now gone.
fn navigate(&mut self, key: KeyEvent) {
let previous = self.explorer.cwd().clone();
let _ = self.explorer.handle(&Event::Key(key));
if *self.explorer.cwd() == previous {
// Nothing to navigate into — keep the query and the highlight.
return;
}
self.query.clear();
self.refresh_matches();
self.select_first_entry();
}
/// Highlights the first real entry, skipping the `../` link the explorer
/// lands on after every directory change — otherwise Right, pressed twice,
/// descends and then bounces straight back up.
fn select_first_entry(&mut self) {
let skip_parent = self.explorer.cwd().parent().is_some() && self.matches.len() > 1;
self.select_match(usize::from(skip_parent));
}
fn move_cursor(&mut self, delta: isize, wrap: bool) {
if self.matches.is_empty() {
return;
}
let len = self.matches.len() as isize;
let cursor = self.cursor().unwrap_or_default() as isize + delta;
let cursor = match wrap {
true => cursor.rem_euclid(len),
false => cursor.clamp(0, len - 1),
};
self.select_match(cursor as usize);
}
fn select_match(&mut self, cursor: usize) {
match self.matches.get(cursor) {
Some(&index) => {
self.explorer.set_selected_idx(index);
self.list_state.select(Some(cursor));
}
None => self.list_state.select(None),
}
}
/// Recomputes the visible entries. Keeps the highlighted entry if it still
/// matches, otherwise falls back to the first match.
fn refresh_matches(&mut self) {
self.matches = self
.explorer
.files()
.iter()
.enumerate()
.filter(|(_, file)| matches_query(&self.query, &file.name))
.map(|(index, _)| index)
.collect();
let cursor = self.cursor().unwrap_or_default();
self.select_match(cursor);
}
pub fn draw(&mut self) {
let Self {
explorer,
terminal,
selected,
query,
matches,
list_state,
..
} = self;
let help = format!(
" Type: filter ←/→: folder Space: select ({}) Enter: confirm Esc: clear/cancel",
selected.len()
);
let _ = terminal.draw(|frame| {
let [main_area, selected_area, help_area] = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(1),
Constraint::Length(1),
])
.areas(frame.area());
let mut block = Block::default()
.borders(Borders::ALL)
.title_top(format!(" {} ", explorer.cwd().display()));
if !query.is_empty() {
let hits = match matches.is_empty() {
true => " no matches".to_string(),
false => format!(" {} of {}", matches.len(), explorer.files().len()),
};
block = block.title_bottom(format!(" Filter: {query} ({hits}) "));
}
let items = matches
.iter()
.filter_map(|&index| explorer.files().get(index))
.map(|file| {
let style = match file.is_dir {
true => Style::default().fg(Color::LightBlue),
false => Style::default().fg(Color::White),
};
Span::styled(file.name.clone(), style)
});
let list = List::new(items)
.block(block)
.highlight_spacing(HighlightSpacing::Always)
.highlight_style(Style::default().bg(Color::DarkGray));
frame.render_stateful_widget(list, main_area, list_state);
let selected_line = match selected.is_empty() {
true => " No files selected".to_string(),
false => format!(
" Selected: {}",
selected
.iter()
.filter_map(|path| path.file_name())
.map(|name| name.to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join(", ")
),
};
frame.render_widget(Paragraph::new(selected_line), selected_area);
frame.render_widget(Paragraph::new(help), help_area);
});
}
}
/// Case-insensitive subsequence match, so `myrep` finds `My Report.pdf`
/// even though Space cannot be typed into the query.
fn matches_query(query: &str, name: &str) -> bool {
let name = name.to_lowercase();
let mut name = name.chars();
query
.to_lowercase()
.chars()
.all(|needle| name.any(|c| c == needle))
}
+270
View File
@@ -0,0 +1,270 @@
use crate::app::AppEvent;
use crate::devices::Device;
use crate::storage::Identity;
use crate::ui::Category;
use crate::util;
use bytes::Bytes;
use futures_util::StreamExt;
use localsend::http::client::ClientError;
use localsend::http::client::v2::LsHttpClientV2;
use localsend::http::dto::ProtocolType;
use localsend::http::dto_v2::{PrepareUploadRequestDtoV2, ProtocolTypeV2};
use localsend::model::transfer::{FileContent, FileDto};
use localsend::reqwest;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::Instant;
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;
use tokio_util::sync::CancellationToken;
/// Cancellation state of a send, shared between the app and the send task.
#[derive(Clone)]
pub struct SendCancel {
pub token: CancellationToken,
/// Set (before triggering `token`) when the receiver requested the
/// cancellation; only a local cancellation still has to notify the
/// receiver.
pub by_peer: Arc<AtomicBool>,
}
impl SendCancel {
pub fn new() -> Self {
Self {
token: CancellationToken::new(),
by_peer: Arc::new(AtomicBool::new(false)),
}
}
}
/// Sends the given files to a device: prepare-upload, then one upload request
/// per accepted file. Progress is reported through `progress` (cumulative
/// bytes over all files) and log lines through `events`.
///
/// Always ends by emitting [AppEvent::SendEnded].
pub async fn run_send(
identity: Arc<Identity>,
device: Device,
files: HashMap<String, FileDto>,
paths: HashMap<String, PathBuf>,
progress: Arc<AtomicU64>,
cancel: SendCancel,
events: mpsc::Sender<AppEvent>,
) {
send_inner(identity, device, files, paths, progress, cancel, &events).await;
let _ = events.send(AppEvent::SendEnded).await;
}
async fn send_inner(
identity: Arc<Identity>,
device: Device,
files: HashMap<String, FileDto>,
paths: HashMap<String, PathBuf>,
progress: Arc<AtomicU64>,
cancel: SendCancel,
events: &mpsc::Sender<AppEvent>,
) {
let alias = device.alias.clone();
let log = |text: String| {
let events = events.clone();
async move {
let _ = events
.send(AppEvent::Log {
category: Category::Send,
text,
})
.await;
}
};
let protocol = match device.protocol {
ProtocolTypeV2::Http => ProtocolType::Http,
ProtocolTypeV2::Https => ProtocolType::Https,
};
let expected_fingerprint = match device.protocol {
ProtocolTypeV2::Https => Some(device.fingerprint.clone()),
ProtocolTypeV2::Http => None,
};
let client = match LsHttpClientV2::try_new(
&identity.key_pem,
&identity.cert_pem,
expected_fingerprint,
None,
) {
Ok(client) => client,
Err(err) => {
log(format!("{alias}: Failed to create HTTP client: {err}")).await;
return;
}
};
let offered = files.len();
let payload = PrepareUploadRequestDtoV2 {
info: identity.register_dto(),
files: files.clone(),
};
let prepared = match client
.prepare_upload(
protocol.clone(),
&device.host,
device.port,
None,
payload,
None,
cancel.token.clone(),
)
.await
{
Ok(prepared) => prepared,
Err(ClientError::Cancelled) => {
log(format!("{alias}: Cancelled")).await;
return;
}
Err(ClientError::StatusCode(err)) => {
let reason = match err.status {
401 => "PIN required (not supported by the CLI)".to_string(),
403 => "Declined".to_string(),
409 => "Blocked by another session".to_string(),
429 => "Too many requests".to_string(),
status => format!(
"Request failed with status {status}{}",
err.message
.map(|message| format!(": {message}"))
.unwrap_or_default()
),
};
log(format!("{alias}: {reason}")).await;
return;
}
Err(err) => {
log(format!("{alias}: {err}")).await;
return;
}
};
let Some(response) = prepared.response else {
log(format!("{alias}: all files were declined")).await;
return;
};
let accepted_bytes: u64 = response
.files
.keys()
.filter_map(|file_id| files.get(file_id))
.map(|file| file.size)
.sum();
let _ = events
.send(AppEvent::SendSessionStarted {
session_id: response.session_id.clone(),
accepted_bytes,
})
.await;
if response.files.len() < offered {
log(format!(
"{alias}: receiver accepted {} of {offered} files",
response.files.len()
))
.await;
}
// Upload sequentially in a stable order.
let mut file_ids: Vec<&String> = response.files.keys().collect();
file_ids.sort_by_key(|file_id| &files[*file_id].file_name);
let started = Instant::now();
let mut sent_files = 0usize;
let mut sent_bytes = 0u64;
for file_id in file_ids {
let token = &response.files[file_id];
let file = &files[file_id];
let path = paths[file_id].clone();
let body = {
let progress = progress.clone();
let base = sent_bytes;
upload_body(FileContent::Path(path), move |bytes_of_file| {
progress.store(base + bytes_of_file, Ordering::Relaxed);
})
};
match client
.upload(
protocol.clone(),
&device.host,
device.port,
None,
&response.session_id,
file_id,
token,
body,
cancel.token.clone(),
)
.await
{
Ok(()) => {
sent_files += 1;
sent_bytes += file.size;
progress.store(sent_bytes, Ordering::Relaxed);
}
Err(ClientError::Cancelled) => {
if cancel.by_peer.load(Ordering::Relaxed) {
log(format!(
"{alias}: cancelled by receiver ({sent_files} file(s) sent)"
))
.await;
} else {
// Cancelled locally: the receiver does not know yet.
let _ = client
.cancel(
protocol.clone(),
&device.host,
device.port,
&response.session_id,
)
.await;
log(format!("{alias}: cancelled ({sent_files} file(s) sent)")).await;
}
return;
}
Err(err) => {
log(format!(
"{alias}: failed to upload {}: {err}",
file.file_name
))
.await;
let _ = client
.cancel(
protocol.clone(),
&device.host,
device.port,
&response.session_id,
)
.await;
return;
}
}
}
log(format!(
"{alias}: Sent {sent_files} file{} ({}, took {})",
if sent_files == 1 { "" } else { "s" },
util::format_bytes(sent_bytes),
util::format_duration(started.elapsed()),
))
.await;
}
/// Builds a streaming request body from the file content, invoking `progress`
/// with the cumulative number of bytes of this file as chunks are sent.
fn upload_body(content: FileContent, progress: impl Fn(u64) + Send + 'static) -> reqwest::Body {
let mut sent = 0u64;
let stream = ReceiverStream::new(content.into_receiver()).map(move |chunk| {
sent += chunk.len() as u64;
progress(sent);
Ok::<Bytes, anyhow::Error>(chunk)
});
reqwest::Body::wrap_stream(stream)
}
+92
View File
@@ -0,0 +1,92 @@
//! `config.toml`: optional user settings. A commented template is written
//! on the first run; command-line flags and environment variables take
//! precedence.
use crate::Args;
use anyhow::Context;
use serde::Deserialize;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
#[derive(Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
pub alias: Option<String>,
pub port: Option<u16>,
pub destination: Option<PathBuf>,
}
const CONFIG_TEMPLATE: &str = "\
# LocalSend CLI configuration. Command-line flags and environment variables
# (LOCALSEND_ALIAS, LOCALSEND_PORT, LOCALSEND_DESTINATION) take precedence.
# Device name shown to other devices (default: the hostname).
#alias = \"My Device\"
# Port of the HTTP server.
#port = 53317
# Directory where received files are saved (default: the system Downloads folder).
#destination = \"~/Downloads\"
";
/// Default port of the HTTP server.
const DEFAULT_PORT: u16 = 53317;
/// The settings actually used by the app.
pub struct ResolvedConfig {
pub alias: String,
pub port: u16,
pub destination: PathBuf,
}
/// Reads `config.toml` and resolves every setting, in order of precedence:
/// command-line flag > environment variable > config file > default
///
/// The environment variables are read by clap.
pub fn load_with_fallback(dir: &Path, args: &Args) -> anyhow::Result<ResolvedConfig> {
let config = load(dir)?;
Ok(ResolvedConfig {
alias: args
.alias
.clone()
.or(config.alias)
.unwrap_or_else(default_alias),
port: args.port.or(config.port).unwrap_or(DEFAULT_PORT),
destination: match args.destination.clone().or(config.destination) {
Some(destination) => expand_tilde(destination),
None => dirs::download_dir().unwrap_or_else(|| PathBuf::from(".")),
},
})
}
/// Reads `config.toml`, writing a commented template when it is missing.
fn load(dir: &Path) -> anyhow::Result<Config> {
let path = dir.join("config.toml");
match std::fs::read_to_string(&path) {
Ok(text) => toml::from_str(&text)
.with_context(|| format!("Invalid config file: {}", path.display())),
Err(err) if err.kind() == ErrorKind::NotFound => {
// Best effort: a missing template is no reason to refuse to run.
let _ = std::fs::write(&path, CONFIG_TEMPLATE);
Ok(Config::default())
}
Err(err) => Err(err).context(format!("Could not read {}", path.display())),
}
}
fn default_alias() -> String {
gethostname::gethostname()
.to_string_lossy()
.trim_end_matches(".local")
.to_string()
}
/// Replaces a leading `~/` with the home directory; config values are not
/// expanded by the shell.
fn expand_tilde(path: PathBuf) -> PathBuf {
match (path.strip_prefix("~"), dirs::home_dir()) {
(Ok(rest), Some(home)) => home.join(rest),
_ => path,
}
}
+148
View File
@@ -0,0 +1,148 @@
//! `identity.pem`: this device's certificate and private key.
use anyhow::Context;
use localsend::crypto::cert::fingerprint_from_cert_der;
use localsend::http::dto_v2::{PROTOCOL_VERSION_V2, ProtocolTypeV2, RegisterDtoV2};
use localsend::http::server::TlsConfig;
use localsend::http::state::ClientInfo;
use localsend::model::discovery::DeviceType;
use localsend::multicast::MulticastDevice;
use std::path::Path;
/// This device's identity: a self-signed certificate whose SHA-256
/// fingerprint identifies the device. The certificate is persisted as
/// `identity.pem` so the fingerprint — and thereby pairings, on both
/// sides — survives restarts.
pub struct Identity {
pub alias: String,
pub port: u16,
pub cert_pem: String,
pub key_pem: String,
pub fingerprint: String,
}
impl Identity {
/// Loads the identity from `identity.pem` in `dir`, generating and
/// saving a fresh one when the file does not exist yet.
pub fn load_or_generate(dir: &Path, alias: String, port: u16) -> anyhow::Result<Self> {
let path = dir.join("identity.pem");
match std::fs::read_to_string(&path) {
Ok(text) => Self::from_pem(&text, alias, port).with_context(|| {
format!(
"Invalid identity file: {} (delete it to generate a new identity; \
other devices will then see this device as unpaired)",
path.display()
)
}),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
let identity = Self::generate(alias, port)?;
identity
.save(&path)
.with_context(|| format!("Could not save {}", path.display()))?;
Ok(identity)
}
Err(err) => Err(err).context(format!("Could not read {}", path.display())),
}
}
fn from_pem(text: &str, alias: String, port: u16) -> anyhow::Result<Self> {
let blocks = pem::parse_many(text)?;
let cert = blocks
.iter()
.find(|block| block.tag() == "CERTIFICATE")
.context("missing CERTIFICATE block")?;
let key = blocks
.iter()
.find(|block| block.tag().ends_with("PRIVATE KEY"))
.context("missing PRIVATE KEY block")?;
let key_pem = pem::encode(key);
rcgen::KeyPair::from_pem(&key_pem).context("unusable private key")?;
Ok(Self {
alias,
port,
fingerprint: fingerprint_from_cert_der(cert.contents()),
cert_pem: pem::encode(cert),
key_pem,
})
}
fn save(&self, path: &Path) -> anyhow::Result<()> {
let contents = format!("{}{}", self.cert_pem, self.key_pem);
#[cfg(unix)]
{
// The file contains the private key; keep it owner-readable only.
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)?;
file.write_all(contents.as_bytes())?;
}
#[cfg(not(unix))]
std::fs::write(path, contents)?;
Ok(())
}
fn generate(alias: String, port: u16) -> anyhow::Result<Self> {
let key_pair = rcgen::KeyPair::generate()?;
let mut params = rcgen::CertificateParams::default();
params.distinguished_name = rcgen::DistinguishedName::new();
params
.distinguished_name
.push(rcgen::DnType::CommonName, "LocalSend User");
let cert = params.self_signed(&key_pair)?;
Ok(Self {
alias,
port,
fingerprint: fingerprint_from_cert_der(cert.der()),
cert_pem: cert.pem(),
key_pem: key_pair.serialize_pem(),
})
}
pub fn tls_config(&self) -> TlsConfig {
TlsConfig {
cert: self.cert_pem.clone(),
private_key: self.key_pem.clone(),
}
}
pub fn client_info(&self) -> ClientInfo {
ClientInfo {
alias: self.alias.clone(),
version: PROTOCOL_VERSION_V2.to_string(),
device_model: Some("CLI".to_string()),
device_type: Some(DeviceType::Headless),
token: self.fingerprint.clone(),
}
}
pub fn register_dto(&self) -> RegisterDtoV2 {
RegisterDtoV2 {
alias: self.alias.clone(),
version: PROTOCOL_VERSION_V2.to_string(),
device_model: Some("CLI".to_string()),
device_type: Some(DeviceType::Headless),
fingerprint: self.fingerprint.clone(),
port: self.port,
protocol: ProtocolTypeV2::Https,
download: false,
}
}
pub fn multicast_device(&self) -> MulticastDevice {
MulticastDevice {
alias: self.alias.clone(),
version: PROTOCOL_VERSION_V2.to_string(),
device_model: Some("CLI".to_string()),
device_type: Some(DeviceType::Headless),
fingerprint: self.fingerprint.clone(),
port: self.port,
protocol: ProtocolTypeV2::Https,
download: false,
}
}
}
+74
View File
@@ -0,0 +1,74 @@
//! The CLI's persistence layer. All persistent files live in one directory
//! (see [`Repository::dir`]) and are loaded through the unified
//! [`Repository`]:
//!
//! - `config.toml` ([`config`]): user-edited settings; a commented template
//! is written on the first run.
//! - `identity.pem` ([`identity`]): this device's certificate and private
//! key.
//! - `paired.json` ([`paired`]): paired devices; machine-written.
mod config;
mod identity;
mod paired;
pub use identity::Identity;
pub use paired::PairedDevices;
use crate::Args;
use anyhow::Context;
use std::path::PathBuf;
use std::sync::Arc;
/// Everything the CLI persists, loaded (or initialized) at startup.
pub struct Repository {
/// The directory holding all persistent files.
pub dir: PathBuf,
/// Directory where received files are saved.
pub destination: PathBuf,
/// This device's identity from `identity.pem`, with the resolved alias
/// and port applied.
pub identity: Arc<Identity>,
/// Devices whose transfer requests are auto-accepted; persisted across
/// runs as `paired.json`.
pub paired: PairedDevices,
}
impl Repository {
/// Creates the storage directory if needed and loads every file in it,
/// writing the config template and generating the identity on the
/// first run.
pub fn load(args: &Args) -> anyhow::Result<Self> {
let dir = dir();
std::fs::create_dir_all(&dir)
.with_context(|| format!("Could not create {}", dir.display()))?;
let config = config::load_with_fallback(&dir, args)?;
let paired = PairedDevices::load(&dir)?;
let identity = Arc::new(Identity::load_or_generate(&dir, config.alias, config.port)?);
Ok(Self {
dir,
destination: config.destination,
identity,
paired,
})
}
}
/// The directory holding all persistent files:
/// `$XDG_CONFIG_HOME/localsend-cli`, or `~/.config/localsend-cli`.
///
/// `~/.config` is used on every platform instead of `dirs::config_dir()`:
/// terminal tools conventionally keep their config there even on macOS, and
/// the `-cli` suffix keeps the directory separate from the Flutter app's.
fn dir() -> PathBuf {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
// The XDG spec says to ignore relative paths.
.filter(|path| path.is_absolute())
.or_else(|| dirs::home_dir().map(|home| home.join(".config")))
.unwrap_or_else(|| PathBuf::from("."))
.join("localsend-cli")
}
+95
View File
@@ -0,0 +1,95 @@
//! `paired.json`: paired devices; machine-written.
use anyhow::Context;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
/// Current schema version of `paired.json`. On a schema change,
/// bump this and migrate the old versions in [`PairedDevices::load`].
///
/// Planned v2: trust the peer's permanent ed25519 public key (see
/// `crypto::token` in the core crate) instead of its certificate
/// fingerprint; certificates (RSA) then become ephemeral.
const PAIRED_DEVICES_VERSION: u32 = 1;
#[derive(Serialize, Deserialize)]
pub struct PairedDevice {
pub alias: String,
}
/// The on-disk format of `paired.json`.
#[derive(Serialize, Deserialize)]
struct PairedDevicesFile {
version: u32,
devices: BTreeMap<String, PairedDevice>,
}
/// The paired devices from `paired.json`, keyed by certificate
/// fingerprint. Their transfer requests are accepted without asking.
pub struct PairedDevices {
path: PathBuf,
file: PairedDevicesFile,
}
impl PairedDevices {
pub fn load(dir: &Path) -> anyhow::Result<Self> {
let path = dir.join("paired.json");
let devices = match std::fs::read_to_string(&path) {
Ok(text) => {
// Read the version on its own first: old versions are parsed
// by their own (migration) arm, not by the current schema.
#[derive(Deserialize)]
struct Version {
version: u32,
}
let context = || format!("Invalid paired devices file: {}", path.display());
let Version { version } = serde_json::from_str(&text).with_context(context)?;
match version {
PAIRED_DEVICES_VERSION => {
serde_json::from_str::<PairedDevicesFile>(&text)
.with_context(context)?
.devices
}
version => anyhow::bail!(
"{} has version {version}, but this build supports only version {PAIRED_DEVICES_VERSION}. \
Was it written by a newer LocalSend CLI?",
path.display()
),
}
}
Err(err) if err.kind() == ErrorKind::NotFound => BTreeMap::new(),
Err(err) => return Err(err).context(format!("Could not read {}", path.display())),
};
Ok(Self {
path,
file: PairedDevicesFile {
version: PAIRED_DEVICES_VERSION,
devices,
},
})
}
pub fn contains(&self, fingerprint: &str) -> bool {
self.file.devices.contains_key(fingerprint)
}
/// Adds a device and saves the file. The device stays paired for this
/// run even when saving fails.
pub fn insert(&mut self, fingerprint: String, alias: String) -> anyhow::Result<()> {
self.file
.devices
.insert(fingerprint, PairedDevice { alias });
self.save()
}
fn save(&self) -> anyhow::Result<()> {
// Write-then-rename so a crash cannot leave a truncated file.
let temp = self.path.with_extension("json.tmp");
std::fs::write(&temp, serde_json::to_string_pretty(&self.file)?)
.with_context(|| format!("Could not write {}", temp.display()))?;
std::fs::rename(&temp, &self.path)
.with_context(|| format!("Could not write {}", self.path.display()))
}
}
+184
View File
@@ -0,0 +1,184 @@
use crate::util;
use crossterm::style::{Color, Print, Stylize};
use crossterm::terminal::{Clear, ClearType};
use crossterm::{QueueableCommand, cursor};
use std::io::{Stdout, Write, stdout};
/// The category of a log event, shown as its colored `D` / `R` / `S` tag.
#[derive(Clone, Copy, Debug)]
pub enum Category {
Discovery,
Receive,
Send,
}
impl Category {
pub fn tag(self) -> &'static str {
match self {
Category::Discovery => "D",
Category::Receive => "R",
Category::Send => "S",
}
}
fn color(self) -> Color {
match self {
Category::Discovery => Color::Cyan,
Category::Receive => Color::Green,
Category::Send => Color::Magenta,
}
}
pub fn colored_tag(self) -> String {
self.tag().with(self.color()).to_string()
}
}
/// An append-only log (like `docker logs`) with a single in-place updated
/// status line at the bottom for transfer progress.
///
/// The terminal is in raw mode, so every line break must be `\r\n`.
pub struct Ui {
out: Stdout,
status: Option<String>,
/// While suspended (the file picker owns the alternate screen), log lines
/// are buffered and flushed on [Ui::resume].
suspended: bool,
buffer: Vec<String>,
}
impl Ui {
pub fn new() -> Self {
Self {
out: stdout(),
status: None,
suspended: false,
buffer: Vec::new(),
}
}
/// Stops writing to the terminal; log lines are buffered instead. The
/// picker draws on the alternate screen, so the main screen stays intact
/// underneath.
pub fn suspend(&mut self) {
self.suspended = true;
}
/// Resumes terminal output and flushes the buffered log lines.
pub fn resume(&mut self) {
self.suspended = false;
for line in std::mem::take(&mut self.buffer) {
let _ = self.out.queue(Print(line));
}
self.redraw_status();
let _ = self.out.flush();
}
/// Prints a log block: the first line is prefixed with the category tag,
/// further lines are indented below it.
pub fn log(&mut self, category: Category, text: &str) {
self.print_block(Some(category), text);
}
/// Prints a log line that is not tied to an event category.
pub fn log_plain(&mut self, text: &str) {
self.print_block(None, text);
}
fn print_block(&mut self, category: Option<Category>, text: &str) {
self.clear_status_line();
for (i, line) in text.lines().enumerate() {
let formatted = match (i, category) {
(0, Some(category)) => format!("{} {line}\r\n", category.colored_tag()),
(0, None) => format!("{line}\r\n"),
(_, Some(_)) => format!(" {line}\r\n"),
(_, None) => format!("{line}\r\n"),
};
match self.suspended {
true => self.buffer.push(formatted),
false => {
let _ = self.out.queue(Print(formatted));
}
}
}
self.redraw_status();
let _ = self.out.flush();
}
/// Replaces the status line at the bottom, or removes it with `None`.
pub fn set_status(&mut self, status: Option<String>) {
if status == self.status {
return;
}
self.clear_status_line();
self.status = status;
self.redraw_status();
let _ = self.out.flush();
}
fn clear_status_line(&mut self) {
if !self.suspended && self.status.is_some() {
let _ = self.out.queue(cursor::MoveToColumn(0));
let _ = self.out.queue(Clear(ClearType::CurrentLine));
}
}
fn redraw_status(&mut self) {
if self.suspended {
return;
}
if let Some(status) = &self.status {
let line = truncate_visible(status, util::terminal_width().saturating_sub(1));
let _ = self.out.queue(Print(line));
}
}
}
/// A piece of a string that may contain ANSI escape sequences: either a
/// sequence, which takes no columns, or a single visible character.
enum Segment<'a> {
Escape(&'a str),
Visible(char),
}
/// Splits `s` into escape sequences and visible characters, so that everything
/// measuring or cutting a formatted string agrees on what occupies a column.
///
/// A trailing escape sequence that is never terminated runs to the end of `s`.
fn segments(s: &str) -> impl Iterator<Item = Segment<'_>> {
let mut iter = s.char_indices();
std::iter::from_fn(move || {
let (start, c) = iter.next()?;
if c != '\u{1b}' {
return Some(Segment::Visible(c));
}
// A CSI sequence like `\x1b[36m` is terminated by a letter.
let mut end = start + c.len_utf8();
for (i, c) in iter.by_ref() {
end = i + c.len_utf8();
if c.is_ascii_alphabetic() {
break;
}
}
Some(Segment::Escape(&s[start..end]))
})
}
/// Truncates to at most `max` visible columns, keeping ANSI escape sequences
/// intact (they take no columns and must not be cut in half).
fn truncate_visible(s: &str, max: usize) -> String {
let mut out = String::new();
let mut visible = 0usize;
for segment in segments(s) {
match segment {
Segment::Escape(escape) => out.push_str(escape),
Segment::Visible(_) if visible == max => break,
Segment::Visible(c) => {
visible += 1;
out.push(c);
}
}
}
out
}
+125
View File
@@ -0,0 +1,125 @@
use std::net::Ipv4Addr;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
pub fn format_bytes(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
let mut value = bytes as f64;
let mut unit = 0;
while value >= 1000.0 && unit < UNITS.len() - 1 {
value /= 1000.0;
unit += 1;
}
match unit {
0 => format!("{bytes} B"),
_ => format!("{value:.1} {}", UNITS[unit]),
}
}
pub fn format_speed(bytes_per_sec: f64) -> String {
format!("{}/s", format_bytes(bytes_per_sec.max(0.0) as u64))
}
pub fn format_duration(duration: Duration) -> String {
let secs = duration.as_secs();
let (h, m, s) = (secs / 3600, (secs % 3600) / 60, secs % 60);
if h > 0 {
format!("{h}h {m}m")
} else if m > 0 {
format!("{m}m {s}s")
} else {
format!("{s}s")
}
}
/// The width of the terminal in columns, falling back to 120 when it cannot be
/// determined (e.g. when the output is not a terminal).
pub fn terminal_width() -> usize {
crossterm::terminal::size()
.map(|(w, _)| w as usize)
.unwrap_or(120)
}
pub fn progress_bar(fraction: f64, width: usize) -> String {
let filled = (fraction.clamp(0.0, 1.0) * width as f64).round() as usize;
format!("{}{}", "#".repeat(filled), "-".repeat(width - filled))
}
/// A path in `dir` for `file_name` that does not exist yet, appending
/// ` (1)`, ` (2)`, … before the extension on collisions.
///
/// Only the final path component of `file_name` is used, so a malicious
/// sender cannot escape the target directory.
pub fn unique_path(dir: &Path, file_name: &str) -> PathBuf {
let name = Path::new(file_name)
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| "unnamed".to_string());
let candidate = dir.join(&name);
if !candidate.exists() {
return candidate;
}
let (stem, extension) = match name.rsplit_once('.') {
Some((stem, extension)) if !stem.is_empty() => (stem, format!(".{extension}")),
_ => (name.as_str(), String::new()),
};
(1..)
.map(|i| dir.join(format!("{stem} ({i}){extension}")))
.find(|candidate| !candidate.exists())
.unwrap()
}
/// The IPv4 addresses of all non-loopback interfaces, i.e. the addresses this
/// device can be reached at. Empty when the interfaces cannot be enumerated.
pub fn local_ipv4_addresses() -> Vec<Ipv4Addr> {
let Ok(interfaces) = if_addrs::get_if_addrs() else {
return Vec::new();
};
let mut addresses: Vec<Ipv4Addr> = interfaces
.into_iter()
.filter(|interface| !interface.is_loopback())
.filter_map(|interface| match interface.ip() {
std::net::IpAddr::V4(address) => Some(address),
std::net::IpAddr::V6(_) => None,
})
.collect();
addresses.sort();
addresses.dedup();
addresses
}
/// Estimates the transfer speed from cumulative byte counts, smoothed with an
/// exponential moving average.
pub struct SpeedMeter {
last_bytes: u64,
last_time: Instant,
ema: f64,
}
impl SpeedMeter {
pub fn new() -> Self {
Self {
last_bytes: 0,
last_time: Instant::now(),
ema: 0.0,
}
}
pub fn update(&mut self, bytes_now: u64) -> f64 {
let now = Instant::now();
let dt = now.duration_since(self.last_time).as_secs_f64();
if dt < 0.1 {
return self.ema;
}
let instantaneous = bytes_now.saturating_sub(self.last_bytes) as f64 / dt;
self.ema = match self.ema {
0.0 => instantaneous,
ema => ema * 0.7 + instantaneous * 0.3,
};
self.last_bytes = bytes_now;
self.last_time = now;
self.ema
}
}