Compare commits

..

1 commit

Author SHA1 Message Date
Joe Ardent
e29295246f broken modules 2025-08-14 17:11:01 -07:00
17 changed files with 856 additions and 1067 deletions

588
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,13 +1,12 @@
[package]
name = "jocalsend"
# 1.61803398874989484
#-----------^
version = "1.6.18033988"
#----^
version = "1.6.1"
edition = "2024"
rust-version = "1.89"
authors = ["Joe Ardent <code@ardent.nebcorp.com>"]
keywords = ["p2p", "localsend", "tui", "linux"]
description = "A TUI for LocalSend"
description = "A terminal implementation of the LocalSend protocol"
readme = "README.md"
license-file = "LICENSE.md"
repository = "https://git.kittencollective.com/nebkor/joecalsend"
@ -35,9 +34,9 @@ rustix = { version = "1", default-features = false, features = ["system"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha256 = "1.6"
simsearch = "0.3"
simsearch = "0.2"
thiserror = "2"
tokio = { version = "1", default-features = false, features = ["time", "macros", "rt-multi-thread", "sync"] }
tokio = { version = "1", default-features = false, features = ["time", "macros", "rt-multi-thread"] }
tokio-rustls = { version = "0.26", default-features = false, features = ["tls12", "logging"] }
toml = "0.9"
tower-http = { version = "0.6", features = ["limit"] }

View file

@ -10,12 +10,7 @@ that uses [Ratatui](https://github.com/ratatui/ratatui) to provide an interactiv
application, and is compatible with the official app.
Install with `cargo install jocalsend` (requires [Rust](https://rustup.rs/)); tested on Linux, it
will probably work on Macs but if you're on a Mac, you probably have AirDrop. It's also available in
nixpkgs, and so if you're a NixOS user, `nix-shell -p jocalsend` will do what you expect.
## BLOG POSTS!
- [Announcement post](https://proclamations.nebcorp-hias.com/sundries/jocalsend/)
- [Design and development](https://proclamations.nebcorp-hias.com/rnd/jocalsend-development/)
will probably work on Macs but if you're on a Mac, you probably have AirDrop.
## Capabilities and screenshots
@ -27,21 +22,18 @@ available:
- `S` -> go to the sending screen, defaulting to sending files
- `R` -> go to the receiving screen to approve or deny incoming transfers
- `L` -> go to the logging screen where you can adjust the log level
- `C` -> clear the list of local peers and re-discover them
- `H` or `?` -> go to help screen
- `ESC` -> go back to the previous screen
- `Q` -> exit the application
When in the sending screen, the following are available
Additionally, when in the sending screen, the following are available
- `TAB` -> switch between content selection and peer selection
- `T` -> enter text directly to send, `ESC` to cancel
- `/` -> fuzzy filename search, use `ESC` to stop inputting text
- `P` -> switch to peer selection
- `T` -> switch to entering text to send
- `F` -> switch to selecting files to send (not available when entering text, use `ESC` to exit text entry)
When in the receiving screen, use `A` to approve the incoming transfer request, or `D` to deny it.
Finally, it will also accept commandline arguments to pre-select a file or pre-populate text to
send:
In addition to the interactive commands, it will also accept commandline arguments to pre-select a
file or pre-populate text to send:
```
$ jocalsend -h

View file

@ -1 +1 @@
1.618033988
1.61

View file

@ -22,10 +22,11 @@ obviously COMPLETELY different.
Cool.
## What version is JocalSend now?
## What version is Julid now?
Canonically, see the `VERSION` file. Heretically, once there have been
at enough releases, the version string in the `Cargo.toml` file will
at least three releases, the version string in the `Cargo.toml` file will
always be of the form "1.6.x", where *x* is at least one digit long, starting
with "1". Each subsequent release will append the next digit of *phi* to
*x*.
*x*. The number of releases can be calculated by counting the number of digits
in *x* and adding 2 to that.

View file

@ -1,72 +0,0 @@
use std::path::{Path, PathBuf};
use crossterm::event::Event;
use jocalsend::error::Result;
use ratatui::widgets::WidgetRef;
use ratatui_explorer::FileExplorer;
use simsearch::{SearchOptions, SimSearch};
use tui_input::Input;
#[derive(Clone)]
pub(crate) struct FileFinder {
pub explorer: FileExplorer,
pub fuzzy: SimSearch<usize>,
pub working_dir: Option<PathBuf>,
pub input: Input,
}
impl FileFinder {
pub fn new() -> Result<Self> {
let fuzzy = SimSearch::new_with(
SearchOptions::new()
.stop_words(vec![std::path::MAIN_SEPARATOR_STR.to_string()])
.stop_whitespace(false)
.threshold(0.0),
);
Ok(Self {
explorer: FileExplorer::new()?,
fuzzy,
working_dir: None,
input: Default::default(),
})
}
pub fn handle(&mut self, event: &Event) -> Result<()> {
self.index();
Ok(self.explorer.handle(event)?)
}
pub fn cwd(&self) -> &Path {
self.explorer.cwd()
}
pub fn set_cwd(&mut self, cwd: &Path) -> Result<()> {
self.explorer.set_cwd(cwd)?;
self.index();
Ok(())
}
pub fn widget(&self) -> impl WidgetRef {
self.explorer.widget()
}
pub fn reset_fuzzy(&mut self) {
self.fuzzy.clear();
self.input.reset();
}
pub fn index(&mut self) {
if let Some(owd) = self.working_dir.as_ref()
&& owd == self.cwd()
{
return;
}
self.working_dir = Some(self.cwd().to_path_buf());
self.reset_fuzzy();
for (i, f) in self.explorer.files().iter().enumerate() {
self.fuzzy.insert(i, f.name());
}
}
}

View file

@ -1,341 +0,0 @@
use crossterm::event::{Event, KeyCode, KeyEvent};
use jocalsend::ReceiveDialog;
use log::{debug, error, warn};
use tui_input::backend::crossterm::EventHandler;
use crate::app::{App, CurrentScreen, FileMode, SendingScreen};
impl App {
pub(super) async fn handle_key_event(
&mut self,
key_event: KeyEvent,
event: crossterm::event::Event,
) {
let code = key_event.code;
let mode = self.screen();
match mode {
CurrentScreen::Main
| CurrentScreen::Help
| CurrentScreen::Logging
| CurrentScreen::Receiving => match code {
KeyCode::Char('q') => self.exit().await,
KeyCode::Char('s') => self.send(),
KeyCode::Char('r') => self.recv(),
KeyCode::Char('l') => self.logs(),
KeyCode::Char('m') => self.main(),
KeyCode::Char('h') | KeyCode::Char('?') => self.help(),
KeyCode::Char('c') => self.service.clear_peers().await,
KeyCode::Esc => self.pop(),
_ => match mode {
CurrentScreen::Main | CurrentScreen::Help => {}
CurrentScreen::Logging => match code {
KeyCode::Left => change_log_level(LogDelta::Down),
KeyCode::Right => change_log_level(LogDelta::Up),
_ => {}
},
CurrentScreen::Receiving => match code {
KeyCode::Up => self.receiving_state.select_previous(),
KeyCode::Down => self.receiving_state.select_next(),
KeyCode::Char('a') => self.accept(),
KeyCode::Char('d') => self.deny(),
_ => {}
},
CurrentScreen::Stopping | CurrentScreen::Sending(_) => unreachable!(),
},
},
CurrentScreen::Sending(_) => self.sending_screen(key_event, event).await,
CurrentScreen::Stopping => {}
}
}
async fn sending_screen(&mut self, key_event: KeyEvent, event: Event) {
let mode = self.screen();
let CurrentScreen::Sending(mode) = mode else {
return;
};
let code = key_event.code;
match mode {
SendingScreen::Peers => match code {
KeyCode::Char('q') => self.exit().await,
KeyCode::Char('s') => self.send(),
KeyCode::Char('r') => self.recv(),
KeyCode::Char('l') => self.logs(),
KeyCode::Char('m') => self.main(),
KeyCode::Char('h') | KeyCode::Char('?') => self.help(),
KeyCode::Char('c') => self.service.clear_peers().await,
KeyCode::Char('t') => self.sending_text(),
KeyCode::Tab => self.sending_files(),
KeyCode::Enter => self.send_content().await,
KeyCode::Esc => self.pop(),
_ => {}
},
SendingScreen::Files(FileMode::Picking) => match code {
KeyCode::Char('q') => self.exit().await,
KeyCode::Char('s') => self.send(),
KeyCode::Char('r') => self.recv(),
KeyCode::Char('l') => self.logs(),
KeyCode::Char('m') => self.main(),
KeyCode::Char('h') | KeyCode::Char('?') => self.help(),
KeyCode::Char('c') => self.service.clear_peers().await,
KeyCode::Char('t') => self.sending_text(),
KeyCode::Tab => self.sending_peers(),
KeyCode::Enter => self.chdir_or_send_file().await,
KeyCode::Esc => self.pop(),
KeyCode::Char('/') => self.sending_fuzzy(),
_ => self.file_finder.handle(&event).unwrap_or_default(),
},
SendingScreen::Files(FileMode::Fuzzy) => match code {
KeyCode::Tab => self.sending_peers(),
KeyCode::Enter => self.chdir_or_send_file().await,
KeyCode::Esc => {
self.file_finder.reset_fuzzy();
self.sending_files();
}
KeyCode::Up | KeyCode::Down => {
if let Err(e) = self.file_finder.handle(&event) {
log::error!("error selecting file: {e:?}");
}
}
_ => {
self.file_finder.index();
if let Some(changed) = self.file_finder.input.handle_event(&event)
&& changed.value
{
let id = self
.file_finder
.fuzzy
.search(self.file_finder.input.value())
.first()
.copied()
.unwrap_or(0);
self.file_finder.explorer.set_selected_idx(id);
}
}
},
SendingScreen::Text => match code {
KeyCode::Tab => self.sending_peers(),
KeyCode::Enter => self.send_text().await,
KeyCode::Esc => {
self.text = None;
self.input.reset();
self.sending_files();
}
_ => {
if let Some(changed) = self.input.handle_event(&event)
&& changed.value
{
if self.input.value().is_empty() {
self.text = None;
} else {
self.text = Some(self.input.to_string());
}
}
}
},
}
}
pub async fn exit(&mut self) {
self.screen.push(CurrentScreen::Stopping);
self.service.stop().await;
}
pub fn send(&mut self) {
let last = self.screen.last();
match last {
Some(CurrentScreen::Sending(_)) => {}
_ => self
.screen
.push(CurrentScreen::Sending(SendingScreen::Files(
FileMode::Picking,
))),
}
}
pub fn recv(&mut self) {
let last = self.screen.last();
match last {
Some(CurrentScreen::Receiving) => {}
_ => self.screen.push(CurrentScreen::Receiving),
}
}
pub fn logs(&mut self) {
let last = self.screen.last();
match last {
Some(CurrentScreen::Logging) => {}
_ => self.screen.push(CurrentScreen::Logging),
}
}
pub fn pop(&mut self) {
self.screen.pop();
if self.screen.last().is_none() {
self.screen.push(CurrentScreen::Main);
}
}
pub fn main(&mut self) {
let last = self.screen.last();
match last {
Some(CurrentScreen::Main) => {}
_ => self.screen.push(CurrentScreen::Main),
}
}
pub fn help(&mut self) {
let last = self.screen.last();
match last {
Some(CurrentScreen::Help) => {}
_ => self.screen.push(CurrentScreen::Help),
}
}
fn sending_peers(&mut self) {
if let CurrentScreen::Sending(mode) = self.screen_mut() {
*mode = SendingScreen::Peers;
}
}
fn sending_text(&mut self) {
if let CurrentScreen::Sending(mode) = self.screen_mut() {
*mode = SendingScreen::Text;
}
}
fn sending_fuzzy(&mut self) {
if let CurrentScreen::Sending(mode) = self.screen_mut() {
*mode = SendingScreen::Files(FileMode::Fuzzy);
}
}
fn sending_files(&mut self) {
let doing_files = self.text.is_none();
let doing_picking = self.file_finder.input.value().is_empty();
let screen = self.screen_mut();
if let CurrentScreen::Sending(mode) = screen {
if doing_files {
if doing_picking {
*mode = SendingScreen::Files(FileMode::Picking);
} else {
*mode = SendingScreen::Files(FileMode::Fuzzy);
}
} else {
*mode = SendingScreen::Text;
}
}
}
// accept a content receive request
fn accept(&mut self) {
let Some(idx) = self.receiving_state.selected() else {
return;
};
// keys are sorted, so we can use the table selection index
let keys: Vec<_> = self.receive_requests.keys().collect();
let Some(key) = keys.get(idx) else {
warn!("could not get id from selection index {idx}");
return;
};
let Some(req) = self.receive_requests.get(key) else {
return;
};
if let Err(e) = req.tx.send(ReceiveDialog::Approve) {
error!("got error sending upload confirmation: {e:?}");
};
}
// reject an content receive request
fn deny(&mut self) {
let Some(idx) = self.receiving_state.selected() else {
return;
};
// keys are sorted, so we can use the table selection index
let keys: Vec<_> = self.receive_requests.keys().cloned().collect();
let Some(key) = keys.get(idx) else {
warn!("could not get id from selection index {idx}");
return;
};
let Some(req) = self.receive_requests.get(key).cloned() else {
return;
};
if let Err(e) = req.tx.send(ReceiveDialog::Deny) {
error!("got error sending upload confirmation: {e:?}");
};
self.receive_requests.remove(key);
}
async fn chdir_or_send_file(&mut self) {
let file = self.file_finder.explorer.current().path().clone();
if file.is_dir()
&& let Err(e) = self.file_finder.set_cwd(&file)
{
error!("could not list directory {file:?}: {e}");
return;
} else if file.is_dir() {
self.file_finder.input.reset();
return;
}
let Some(peer_idx) = self.peer_state.selected() else {
warn!("no peer selected to send to");
return;
};
let Some(peer) = self.peers.get(peer_idx) else {
warn!("invalid peer index {peer_idx}");
return;
};
if file.is_file() {
debug!("sending {file:?}");
if let Err(e) = self.service.send_file(&peer.fingerprint, file).await {
error!("got error sending content: {e:?}");
}
}
}
// send content to selected peer, or change directories in the file explorer
async fn send_text(&mut self) {
debug!("sending text");
let Some(peer_idx) = self.peer_state.selected() else {
debug!("no peer selected to send to");
return;
};
let Some(peer) = self.peers.get(peer_idx) else {
warn!("invalid peer index {peer_idx}");
return;
};
let Some(text) = &self.text else {
debug!("no text to send");
return;
};
if let Err(e) = self.service.send_text(&peer.fingerprint, text).await {
error!("got error sending \"{text}\" to {}: {e:?}", peer.alias);
}
}
async fn send_content(&mut self) {
if self.text.is_some() {
self.send_text().await;
} else {
self.chdir_or_send_file().await;
}
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
enum LogDelta {
Up,
Down,
}
fn change_log_level(delta: LogDelta) {
let level = match delta {
LogDelta::Up => log::max_level().increment_severity(),
LogDelta::Down => log::max_level().decrement_severity(),
};
log::set_max_level(level);
}

View file

@ -1,24 +1,26 @@
use std::{collections::BTreeMap, net::SocketAddr};
use std::{
collections::BTreeMap,
net::SocketAddr,
path::{Path, PathBuf},
};
use crossterm::event::{Event, EventStream, KeyEventKind};
use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind};
use futures::{FutureExt, StreamExt};
use jocalsend::{JocalEvent, JocalService, ReceiveRequest, error::Result};
use julid::Julid;
use log::{LevelFilter, debug, error, warn};
use ratatui::{
Frame,
widgets::{ListState, TableState},
widgets::{ListState, TableState, WidgetRef},
};
use ratatui_explorer::FileExplorer;
use simsearch::{SearchOptions, SimSearch};
use tokio::sync::mpsc::UnboundedReceiver;
use tui_input::Input;
use tui_input::{Input, backend::crossterm::EventHandler};
use crate::jocalsend::{JocalEvent, JocalService, ReceiveDialog, ReceiveRequest, error::Result};
pub mod widgets;
mod file_finder;
use file_finder::FileFinder;
mod handle;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Peer {
pub alias: String,
@ -26,9 +28,25 @@ pub struct Peer {
pub addr: SocketAddr,
}
#[derive(Clone)]
struct FileFinder {
explorer: FileExplorer,
fuzzy: SimSearch<usize>,
working_dir: Option<PathBuf>,
input: Input,
}
fn searcher() -> SimSearch<usize> {
SimSearch::new_with(
SearchOptions::new()
.stop_words(vec![std::path::MAIN_SEPARATOR_STR.to_string()])
.stop_whitespace(false),
)
}
pub struct App {
pub service: JocalService,
pub terminal_events: EventStream,
pub events: EventStream,
pub peers: Vec<Peer>,
pub peer_state: ListState,
pub receive_requests: BTreeMap<Julid, ReceiveRequest>,
@ -36,7 +54,7 @@ pub struct App {
receiving_state: TableState,
// for getting messages back from the web server or web client about things we've done; the
// other end is held by the service
jocal_event_rx: UnboundedReceiver<JocalEvent>,
event_listener: UnboundedReceiver<JocalEvent>,
file_finder: FileFinder,
text: Option<String>,
input: Input,
@ -49,7 +67,6 @@ pub enum CurrentScreen {
Receiving,
Stopping,
Logging,
Help,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -69,11 +86,11 @@ impl App {
pub fn new(service: JocalService, event_listener: UnboundedReceiver<JocalEvent>) -> Self {
App {
service,
jocal_event_rx: event_listener,
event_listener,
screen: vec![CurrentScreen::Main],
file_finder: FileFinder::new().expect("could not create file explorer"),
text: None,
terminal_events: Default::default(),
events: Default::default(),
peers: Default::default(),
peer_state: Default::default(),
receive_requests: Default::default(),
@ -84,8 +101,20 @@ impl App {
pub async fn handle_events(&mut self) -> Result<()> {
tokio::select! {
jocal_event = self.jocal_event_rx.recv().fuse() => {
if let Some(event) = jocal_event {
event = self.events.next().fuse() => {
if let Some(Ok(evt)) = event {
match evt {
Event::Key(key)
if key.kind == KeyEventKind::Press
=> self.handle_key_event(key, evt).await,
Event::Mouse(_) => {}
Event::Resize(_, _) => {}
_ => {}
}
}
}
transfer_event = self.event_listener.recv().fuse() => {
if let Some(event) = transfer_event {
log::trace!("got JocalEvent {event:?}");
match event {
JocalEvent::ReceiveRequest { id, request } => {
@ -102,19 +131,6 @@ impl App {
}
}
}
terminal_event = self.terminal_events.next().fuse() => {
if let Some(Ok(evt)) = terminal_event {
match evt {
Event::Key(key)
if key.kind == KeyEventKind::Press
=> self.handle_key_event(key, evt).await,
Event::Mouse(_) => {}
Event::Resize(_, _) => {}
_ => {}
}
}
}
}
Ok(())
@ -139,7 +155,335 @@ impl App {
self.screen.last_mut().unwrap()
}
async fn handle_key_event(&mut self, key_event: KeyEvent, event: crossterm::event::Event) {
let code = key_event.code;
let mode = self.screen.last_mut().unwrap();
match mode {
CurrentScreen::Main
| CurrentScreen::Logging
| CurrentScreen::Receiving
| CurrentScreen::Sending(SendingScreen::Files(FileMode::Picking))
| CurrentScreen::Sending(SendingScreen::Peers) => match code {
KeyCode::Char('q') => self.exit().await,
KeyCode::Char('s') => self.send(),
KeyCode::Char('r') => self.recv(),
KeyCode::Char('l') => self.logs(),
KeyCode::Char('m') => self.main(),
KeyCode::Esc => self.pop(),
_ => match mode {
CurrentScreen::Main => {
if let KeyCode::Char('d') = code {
self.service.refresh_peers().await
}
}
CurrentScreen::Logging => match code {
KeyCode::Left => change_log_level(-1),
KeyCode::Right => change_log_level(1),
_ => {}
},
CurrentScreen::Receiving => match code {
KeyCode::Up => self.receiving_state.select_previous(),
KeyCode::Down => self.receiving_state.select_next(),
KeyCode::Char('a') => self.accept(),
KeyCode::Char('d') => self.deny(),
_ => {}
},
CurrentScreen::Sending(sending_screen) => match sending_screen {
// we can only be in picking mode
SendingScreen::Files(fmode) => match code {
KeyCode::Char('t') => *sending_screen = SendingScreen::Text,
KeyCode::Tab => *sending_screen = SendingScreen::Peers,
KeyCode::Enter => self.chdir_or_send_file().await,
KeyCode::Char('/') => {
*fmode = FileMode::Fuzzy;
}
_ => self.file_finder.handle(&event).unwrap_or_default(),
},
SendingScreen::Peers => match code {
KeyCode::Tab => {
*sending_screen = SendingScreen::Files(FileMode::Picking)
}
KeyCode::Char('t') => *sending_screen = SendingScreen::Text,
KeyCode::Enter => self.send_content().await,
KeyCode::Up => self.peer_state.select_previous(),
KeyCode::Down => self.peer_state.select_next(),
_ => {}
},
SendingScreen::Text => unreachable!(),
},
CurrentScreen::Stopping => unreachable!(),
},
},
// we only need to deal with sending text now or doing fuzzy matching
CurrentScreen::Sending(sending_screen) => match sending_screen {
SendingScreen::Text => match code {
KeyCode::Tab => *sending_screen = SendingScreen::Peers,
KeyCode::Enter => self.send_text().await,
KeyCode::Esc => {
self.text = None;
self.input.reset();
*sending_screen = SendingScreen::Files(FileMode::Picking);
}
_ => {
if let Some(changed) = self.input.handle_event(&event)
&& changed.value
{
if self.input.value().is_empty() {
self.text = None;
} else {
self.text = Some(self.input.to_string());
}
}
}
},
SendingScreen::Files(fmode) => {
if *fmode == FileMode::Fuzzy {
match code {
KeyCode::Tab => *sending_screen = SendingScreen::Peers,
KeyCode::Enter => self.chdir_or_send_file().await,
KeyCode::Esc => {
self.file_finder.reset_fuzzy();
*fmode = FileMode::Picking;
}
KeyCode::Up | KeyCode::Down => {
if let Err(e) = self.file_finder.handle(&event) {
log::error!("error selecting file: {e:?}");
}
}
_ => {
self.file_finder.index();
if let Some(changed) = self.file_finder.input.handle_event(&event)
&& changed.value
{
let id = self
.file_finder
.fuzzy
.search(self.file_finder.input.value())
.first()
.copied()
.unwrap_or(0);
self.file_finder.explorer.set_selected_idx(id);
}
}
}
}
}
SendingScreen::Peers => unreachable!(),
},
CurrentScreen::Stopping => {}
}
}
pub fn draw(&mut self, frame: &mut Frame) {
frame.render_widget(self, frame.area());
}
pub async fn exit(&mut self) {
self.screen.push(CurrentScreen::Stopping);
self.service.stop().await;
}
pub fn send(&mut self) {
let last = self.screen.last();
match last {
Some(CurrentScreen::Sending(_)) => {}
_ => self
.screen
.push(CurrentScreen::Sending(SendingScreen::Files(
FileMode::Picking,
))),
}
}
pub fn recv(&mut self) {
let last = self.screen.last();
match last {
Some(CurrentScreen::Receiving) => {}
_ => self.screen.push(CurrentScreen::Receiving),
}
}
pub fn logs(&mut self) {
let last = self.screen.last();
match last {
Some(CurrentScreen::Logging) => {}
_ => self.screen.push(CurrentScreen::Logging),
}
}
pub fn pop(&mut self) {
self.screen.pop();
if self.screen.last().is_none() {
self.screen.push(CurrentScreen::Main);
}
}
pub fn main(&mut self) {
let last = self.screen.last();
match last {
Some(CurrentScreen::Main) => {}
_ => self.screen.push(CurrentScreen::Main),
}
}
// accept a content receive request
fn accept(&mut self) {
let Some(idx) = self.receiving_state.selected() else {
return;
};
// keys are sorted, so we can use the table selection index
let keys: Vec<_> = self.receive_requests.keys().collect();
let Some(key) = keys.get(idx) else {
warn!("could not get id from selection index {idx}");
return;
};
let Some(req) = self.receive_requests.get(key) else {
return;
};
if let Err(e) = req.tx.send(ReceiveDialog::Approve) {
error!("got error sending upload confirmation: {e:?}");
};
}
// reject an content receive request
fn deny(&mut self) {
let Some(idx) = self.receiving_state.selected() else {
return;
};
// keys are sorted, so we can use the table selection index
let keys: Vec<_> = self.receive_requests.keys().cloned().collect();
let Some(key) = keys.get(idx) else {
warn!("could not get id from selection index {idx}");
return;
};
let Some(req) = self.receive_requests.get(key).cloned() else {
return;
};
if let Err(e) = req.tx.send(ReceiveDialog::Deny) {
error!("got error sending upload confirmation: {e:?}");
};
self.receive_requests.remove(key);
}
async fn chdir_or_send_file(&mut self) {
let file = self.file_finder.explorer.current().path().clone();
if file.is_dir()
&& let Err(e) = self.file_finder.set_cwd(&file)
{
error!("could not list directory {file:?}: {e}");
return;
} else if file.is_dir() {
return;
}
let Some(peer_idx) = self.peer_state.selected() else {
warn!("no peer selected to send to");
return;
};
let Some(peer) = self.peers.get(peer_idx) else {
warn!("invalid peer index {peer_idx}");
return;
};
if file.is_file() {
debug!("sending {file:?}");
if let Err(e) = self.service.send_file(&peer.fingerprint, file).await {
error!("got error sending content: {e:?}");
}
}
}
// send content to selected peer, or change directories in the file explorer
async fn send_text(&mut self) {
debug!("sending text");
let Some(peer_idx) = self.peer_state.selected() else {
debug!("no peer selected to send to");
return;
};
let Some(peer) = self.peers.get(peer_idx) else {
warn!("invalid peer index {peer_idx}");
return;
};
let Some(text) = &self.text else {
debug!("no text to send");
return;
};
if let Err(e) = self.service.send_text(&peer.fingerprint, text).await {
error!("got error sending \"{text}\" to {}: {e:?}", peer.alias);
}
}
async fn send_content(&mut self) {
if self.text.is_some() {
self.send_text().await;
} else {
self.chdir_or_send_file().await;
}
}
}
impl FileFinder {
pub fn new() -> Result<Self> {
Ok(Self {
explorer: FileExplorer::new()?,
fuzzy: searcher(),
working_dir: None,
input: Default::default(),
})
}
pub fn handle(&mut self, event: &Event) -> Result<()> {
self.index();
Ok(self.explorer.handle(event)?)
}
pub fn cwd(&self) -> &Path {
self.explorer.cwd()
}
pub fn set_cwd(&mut self, cwd: &Path) -> Result<()> {
self.explorer.set_cwd(cwd)?;
self.index();
Ok(())
}
pub fn widget(&self) -> impl WidgetRef {
self.explorer.widget()
}
pub fn reset_fuzzy(&mut self) {
self.clear_fuzzy();
self.input.reset();
}
fn clear_fuzzy(&mut self) {
self.fuzzy = searcher();
}
fn index(&mut self) {
if let Some(owd) = self.working_dir.as_ref()
&& owd == self.cwd()
{
return;
}
self.working_dir = Some(self.cwd().to_path_buf());
self.clear_fuzzy();
for (i, f) in self.explorer.files().iter().enumerate() {
self.fuzzy.insert(i, f.name());
}
}
}
fn change_log_level(delta: isize) {
let level = log::max_level() as isize;
let max = log::LevelFilter::max() as isize;
let level = (level + delta).clamp(0, max) as usize;
// levelfilter is repr(usize) so this is safe
let level = unsafe { std::mem::transmute::<usize, LevelFilter>(level) };
log::set_max_level(level);
}

View file

@ -10,7 +10,7 @@ use ratatui::{
text::{Line, Text, ToLine},
widgets::{
Block, Borders, Clear, List, ListItem, ListState, Padding, Paragraph, Row, Table,
TableState, Widget, Wrap,
TableState, Widget,
},
};
use tui_logger::{TuiLoggerLevelOutput, TuiLoggerWidget, TuiWidgetState};
@ -27,8 +27,6 @@ static MAIN_MENU: LazyLock<Line> = LazyLock::new(|| {
"<L>".blue().bold(),
" Previous Screen ".into(),
"<ESC>".blue().bold(),
" Help ".into(),
"<H|?>".blue().bold(),
" Quit ".into(),
"<Q>".blue().bold(),
])
@ -130,11 +128,10 @@ impl Widget for &mut App {
let [header_left, header_right] = header_layout.areas(top);
let header_margin = Margin::new(1, 2);
let top_heavy = Layout::vertical([Constraint::Percentage(66), Constraint::Percentage(34)]);
let [heavy_top, _skinny_bottom] = top_heavy.areas(area);
let subscreen_margin = Margin::new(1, 2);
// it's safe to call `unwrap()` here because we ensure there's always at least
// one element in `self.screen`; see the `self.pop()` method
let current_screen = self.screen();
match current_screen {
CurrentScreen::Main => {
@ -159,10 +156,6 @@ impl Widget for &mut App {
buf,
);
}
CurrentScreen::Help => {
outer_frame(&current_screen, &MAIN_MENU, area, buf);
help_screen(area.inner(subscreen_margin), buf);
}
CurrentScreen::Logging | CurrentScreen::Stopping => {
outer_frame(&current_screen, &LOGGING_MENU, area, buf);
logger(area.inner(subscreen_margin), buf);
@ -192,31 +185,18 @@ impl Widget for &mut App {
}
let file_area = header_left.inner(header_margin);
let cwd = self
.file_finder
.cwd()
.as_os_str()
.to_string_lossy()
.into_owned();
match sending_screen {
SendingScreen::Files(FileMode::Picking)
| SendingScreen::Peers
| SendingScreen::Text => {
let layout = Layout::vertical([Constraint::Length(3), Constraint::Min(5)]);
let [cwd_area, file_area] = layout.areas(file_area);
let cwd: Line = cwd.into();
Paragraph::new(cwd)
.centered()
.block(Block::bordered())
.render(cwd_area, buf);
SendingScreen::Files(FileMode::Picking) => {
self.file_finder.widget().render(file_area, buf);
}
SendingScreen::Files(FileMode::Fuzzy) => {
let layout = Layout::vertical([Constraint::Max(3), Constraint::Min(5)]);
let [input_area, files_area] = layout.areas(file_area);
text_popup(self.file_finder.input.value(), &cwd, input_area, buf);
self.file_finder.widget().render(files_area, buf);
let layout = Layout::vertical([Constraint::Max(6), Constraint::Min(5)]);
let [input, files] = layout.areas(file_area);
text_popup(self.file_finder.input.value(), "fuzzy search", input, buf);
self.file_finder.widget().render(files, buf);
}
_ => {}
}
logger(header_right.inner(header_margin), buf);
@ -229,8 +209,7 @@ impl Widget for &mut App {
);
if sending_screen == SendingScreen::Text {
let rect =
centered_rect(heavy_top, Constraint::Percentage(80), Constraint::Max(6));
let rect = centered_rect(area, Constraint::Percentage(80), Constraint::Max(10));
let text = if let Some(text) = self.text.as_ref() {
text
} else {
@ -264,96 +243,18 @@ fn outer_frame(screen: &CurrentScreen, menu: &Line, area: Rect, buf: &mut Buffer
.render(area, buf);
}
fn help_screen(area: Rect, buf: &mut Buffer) {
let spacer = "".to_line().centered();
let main_bindings = vec![
Row::new(vec!["".to_line(), spacer.clone(), "".to_line()]),
Row::new(vec![
// Sending
"Send data".bold().into_right_aligned_line(),
spacer.clone(),
"S".bold().into_left_aligned_line(),
]),
// Receiving
Row::new(vec![
"Manage incoming transfer requests"
.bold()
.into_right_aligned_line(),
spacer.clone(),
"R".bold().into_left_aligned_line(),
]),
// logging
Row::new(vec![
"View logs and change log level"
.bold()
.into_right_aligned_line(),
spacer.clone(),
"L".bold().into_left_aligned_line(),
]),
// misc: main menu
Row::new(vec![
"Go to the main screen".bold().into_right_aligned_line(),
spacer.clone(),
"M".bold().into_left_aligned_line(),
]),
// misc: clear peers
Row::new(vec![
"Clear peers and rediscover"
.bold()
.into_right_aligned_line(),
spacer.clone(),
"C".bold().into_left_aligned_line(),
]),
// misc: help
Row::new(vec![
"This help screen".bold().into_right_aligned_line(),
spacer.clone(),
"H or ?".bold().into_left_aligned_line(),
]),
// misc: pop
Row::new(vec![
"Go to previous screen".bold().into_right_aligned_line(),
spacer.clone(),
"ESC".bold().into_left_aligned_line(),
]),
// misc: quit
Row::new(vec![
"Quit the application".bold().into_right_aligned_line(),
spacer.clone(),
"Q".bold().into_left_aligned_line(),
]),
];
let layout = Layout::vertical(vec![
Constraint::Max(3),
Constraint::Max(12),
Constraint::Max(3),
])
.flex(Flex::SpaceAround);
let [intro_area, bindings_area, outro_area] = layout.areas(area);
let widths = vec![
Constraint::Percentage(50),
Constraint::Max(6),
Constraint::Percentage(50),
];
let main_bindings = Table::new(main_bindings, widths).header(Row::new(vec![
"Action".bold().into_right_aligned_line(),
spacer,
"Key input".bold().into_left_aligned_line(),
]));
fn text_popup(text: &str, title: &str, area: Rect, buf: &mut Buffer) {
let title = Line::from(title.bold());
let block = Block::bordered().title(title.centered());
Clear.render(area, buf);
let intro = "JocalSend is a mode-based application that responds to key-presses. Most modes support the following key bindings:".to_line().centered();
let intro = Paragraph::new(intro).wrap(Wrap { trim: true });
block.render(area, buf);
let outro = "Additional key bindings are available when in the sending or receiving screens, and are displayed at the bottom of the screen there.".to_line().centered();
let outro = Paragraph::new(outro).wrap(Wrap { trim: true });
let (_, len) = unicode_segmentation::UnicodeSegmentation::graphemes(text, true).size_hint();
let len = len.unwrap_or(text.len()) as u16 + 2;
let area = centered_rect(area, Constraint::Length(len), Constraint::Length(1));
intro.render(intro_area, buf);
main_bindings.render(bindings_area, buf);
outro.render(outro_area, buf);
Paragraph::new(text).centered().yellow().render(area, buf);
}
fn logger(area: Rect, buf: &mut Buffer) {
@ -509,17 +410,3 @@ fn centered_rect(area: Rect, horizontal: Constraint, vertical: Constraint) -> Re
let [area] = Layout::vertical([vertical]).flex(Flex::Center).areas(area);
area
}
fn text_popup(text: &str, title: &str, area: Rect, buf: &mut Buffer) {
let title = Line::from(title.bold());
let block = Block::bordered().title(title.centered());
Clear.render(area, buf);
block.render(area, buf);
let (_, len) = unicode_segmentation::UnicodeSegmentation::graphemes(text, true).size_hint();
let len = len.unwrap_or(text.len()) as u16 + 2;
let area = centered_rect(area, Constraint::Length(len), Constraint::Length(1));
Paragraph::new(text).centered().yellow().render(area, buf);
}

View file

@ -12,8 +12,8 @@ use local_ip_address::local_ip;
use serde::{Deserialize, Deserializer, Serialize};
use crate::{
DEFAULT_PORT, MULTICAST_IP,
error::{LocalSendError, Result},
error::{JocalError, Result},
jocalsend::{DEFAULT_PORT, MULTICAST_IP},
models::Device,
};
@ -41,7 +41,7 @@ impl Default for Config {
impl Config {
pub fn new() -> Result<Self> {
let dirs = directories::BaseDirs::new().ok_or(LocalSendError::NoHomeDir)?;
let dirs = directories::BaseDirs::new().ok_or(JocalError::NoHomeDir)?;
let download_dir = dirs.home_dir().join("jocalsend-downloads");
let config_file = dirs.config_dir().join("jocalsend.toml");
@ -88,7 +88,7 @@ impl Config {
.map_err(Box::new)? // boxed because the error size from figment is large
};
log::debug!("using config: {config:?}");
log::info!("using config: {config:?}");
Ok(config)
}

View file

@ -37,7 +37,7 @@ impl JocalService {
tokio::select! {
_ = timeout.tick() => {
let rstate = {
*self.running_state.read().await
*self.running_state.lock().await
};
if rstate == RunningState::Stopping
{
@ -71,7 +71,7 @@ impl JocalService {
src.set_port(device.port); // Update the port to the one the device sent
{
let mut peers = self.peers.write().await;
let mut peers = self.peers.lock().await;
peers.insert(device.fingerprint.clone(), (src, device.clone()));
}
@ -108,7 +108,7 @@ pub async fn register_device(
addr.set_port(service.config.device.port);
service
.peers
.write()
.lock()
.await
.insert(device.fingerprint.clone(), (addr, device.clone()));
Json(device).into_response()
@ -138,3 +138,33 @@ async fn announce_multicast(
socket.send_to(msg.as_bytes(), addr).await?;
Ok(())
}
/*
async fn announce_unicast(
device: &Device,
ip: Option<SocketAddr>,
client: reqwest::Client,
) -> crate::error::Result<()> {
// for enumerating subnet peers when multicast fails (https://github.com/localsend/protocol?tab=readme-ov-file#32-http-legacy-mode)
let std::net::IpAddr::V4(ip) = local_ip_address::local_ip()? else {
unreachable!()
};
let mut _network_ip = ip;
let nifs = NetworkInterface::show()?;
for addr in nifs.into_iter().flat_map(|i| i.addr) {
if let Addr::V4(V4IfAddr {
ip: ifip,
netmask: Some(netmask),
..
}) = addr
&& ip == ifip
{
_network_ip = ip & netmask;
break;
}
}
todo!()
}
*/

View file

@ -1,5 +1,5 @@
#[derive(Debug, thiserror::Error)]
pub enum LocalSendError {
pub enum JocalError {
#[error("IO error: {0}")]
IOError(#[from] std::io::Error),
@ -64,4 +64,4 @@ pub enum LocalSendError {
ConfigParseError(#[from] Box<figment::Error>),
}
pub type Result<T> = std::result::Result<T, LocalSendError>;
pub type Result<T> = std::result::Result<T, JocalError>;

View file

@ -15,7 +15,7 @@ use tower_http::limit::RequestBodyLimitLayer;
use crate::{
JocalService,
discovery::register_device,
transfer::{handle_cancel, handle_prepare_upload, handle_receive_upload},
transfer::{handle_prepare_upload, handle_receive_upload},
};
impl JocalService {
@ -32,8 +32,6 @@ impl JocalService {
log::info!("starting http server");
// need to make a custom tls acceptor, see
// https://github.com/programatik29/axum-server/blob/master/examples/rustls_session.rs
axum_server::bind_rustls(addr, ssl_config)
.handle(handle)
.serve(app.into_make_service_with_connect_info::<SocketAddr>())
@ -61,7 +59,6 @@ impl JocalService {
post(handle_prepare_upload),
)
.route("/api/localsend/v2/upload", post(handle_receive_upload))
.route("/api/localsend/v2/cancel", post(handle_cancel))
.layer(DefaultBodyLimit::disable())
.layer(RequestBodyLimitLayer::new(1024 * 1024 * 1024))
.with_state(self.clone())

View file

@ -1,10 +1,3 @@
pub mod config;
pub mod discovery;
pub mod error;
pub mod http_server;
pub mod models;
pub mod transfer;
use std::{
collections::BTreeMap,
fmt::Debug,
@ -14,29 +7,34 @@ use std::{
time::Duration,
};
pub use config::Config;
use julid::Julid;
use log::error;
use models::{Device, FileMetadata};
use tokio::{
net::UdpSocket,
sync::{
RwLock,
Mutex,
mpsc::{self, UnboundedReceiver, UnboundedSender},
},
task::JoinSet,
};
use transfer::Session;
use crate::{
config::Config,
models::{Device, FileMetadata},
transfer::Session,
};
pub const DEFAULT_PORT: u16 = 53317;
pub const MULTICAST_IP: Ipv4Addr = Ipv4Addr::new(224, 0, 0, 167);
pub const DEFAULT_INTERVAL: Duration = Duration::from_millis(100);
pub const LISTENING_SOCKET_ADDR: SocketAddrV4 =
SocketAddrV4::new(Ipv4Addr::from_bits(0), DEFAULT_PORT);
pub const DEFAULT_INTERVAL: Duration = Duration::from_millis(200);
pub type Peers = Arc<RwLock<BTreeMap<String, (SocketAddr, Device)>>>;
pub type Sessions = Arc<RwLock<BTreeMap<String, Session>>>; // Session ID to Session
pub type Peers = Arc<Mutex<BTreeMap<String, (SocketAddr, Device)>>>;
pub type Sessions = Arc<Mutex<BTreeMap<String, Session>>>; // Session ID to Session
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JocalTask {
pub enum JocalTasks {
Udp,
Http,
Multicast,
@ -76,7 +74,7 @@ pub struct ReceiveRequest {
impl PartialEq for ReceiveRequest {
fn eq(&self, other: &Self) -> bool {
self.alias == other.alias && self.files == other.files
self.alias == other.alias
}
}
@ -91,18 +89,12 @@ impl Debug for ReceiveRequest {
}
}
impl ReceiveRequest {
pub fn file_names(&self) -> Vec<String> {
self.files.values().map(|f| f.file_name.clone()).collect()
}
}
/// Contains the main network and backend state for an application session.
#[derive(Clone)]
pub struct JocalService {
pub peers: Peers,
pub sessions: Sessions,
pub running_state: Arc<RwLock<RunningState>>,
pub running_state: Arc<Mutex<RunningState>>,
pub socket: Arc<UdpSocket>,
pub client: reqwest::Client,
pub config: Config,
@ -117,11 +109,10 @@ impl JocalService {
config: Config,
) -> crate::error::Result<(Self, UnboundedReceiver<JocalEvent>)> {
let (tx, rx) = mpsc::unbounded_channel();
let addr = SocketAddrV4::new(config.local_ip_addr, DEFAULT_PORT);
let socket = UdpSocket::bind(addr).await?;
socket.set_multicast_loop_v4(false)?;
socket.set_multicast_ttl_v4(1)?; // local subnet only
socket.join_multicast_v4(MULTICAST_IP, *addr.ip())?;
let socket = UdpSocket::bind(LISTENING_SOCKET_ADDR).await?;
socket.set_multicast_loop_v4(true)?;
socket.set_multicast_ttl_v4(8)?; // 8 hops out from localnet
socket.join_multicast_v4(MULTICAST_IP, Ipv4Addr::from_bits(0))?;
let client = reqwest::ClientBuilder::new()
// localsend certs are self-signed
@ -143,16 +134,14 @@ impl JocalService {
))
}
pub async fn start(&self) -> JoinSet<JocalTask> {
pub async fn start(&self, handles: &mut JoinSet<JocalTasks>) {
let service = self.clone();
let mut handles = JoinSet::new();
handles.spawn(async move {
if let Err(e) = service.start_http_server().await {
error!("HTTP server error: {e}");
}
JocalTask::Http
JocalTasks::Http
});
let service = self.clone();
@ -160,7 +149,7 @@ impl JocalService {
if let Err(e) = service.listen_multicast().await {
error!("UDP listener error: {e}");
}
JocalTask::Multicast
JocalTasks::Multicast
});
let service = self.clone();
@ -169,45 +158,38 @@ impl JocalService {
let mut tick = tokio::time::interval(DEFAULT_INTERVAL);
loop {
let rstate = service.running_state.lock().await;
if *rstate == RunningState::Stopping {
break;
}
tick.tick().await;
service
.transfer_event_tx
.send(JocalEvent::Tick)
.unwrap_or_else(|e| log::warn!("could not send tick event: {e:?}"));
let rstate = service.running_state.read().await;
if *rstate == RunningState::Stopping {
break;
}
}
JocalTask::Tick
JocalTasks::Tick
});
let service = self.clone();
handles.spawn(async move {
loop {
if let Err(e) = service.announce(None).await {
error!("Announcement error: {e}");
}
tokio::time::sleep(Duration::from_secs(2)).await;
let rstate = service.running_state.read().await;
let rstate = service.running_state.lock().await;
if *rstate == RunningState::Stopping {
break;
}
if let Err(e) = service.announce(None).await {
error!("Announcement error: {e}");
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
JocalTask::Udp
JocalTasks::Udp
});
handles
}
pub async fn stop(&self) {
{
let mut rstate = self.running_state.write().await;
*rstate = RunningState::Stopping;
}
let mut rstate = self.running_state.lock().await;
*rstate = RunningState::Stopping;
log::info!("shutting down http server");
self.http_handle
.get()
@ -215,8 +197,8 @@ impl JocalService {
.graceful_shutdown(Some(Duration::from_secs(5)));
}
pub async fn clear_peers(&self) {
let mut peers = self.peers.write().await;
pub async fn refresh_peers(&self) {
let mut peers = self.peers.lock().await;
peers.clear();
}

View file

@ -1,12 +1,11 @@
use std::{path::Path, str::FromStr, time::Duration};
use clap::Parser;
use jocalsend::{Config, DEFAULT_INTERVAL, JocalService, JocalTask, error::Result};
use log::{error, info};
use ratatui::DefaultTerminal;
use ratatui_explorer::FileExplorer;
use tokio::task::JoinSet;
use tui_logger::{LevelFilter, init_logger};
use tui_logger::{LevelFilter, init_logger, set_env_filter_from_env};
mod app;
use app::{App, CurrentScreen, Peer};
@ -14,15 +13,27 @@ use app::{App, CurrentScreen, Peer};
mod cli;
use cli::Cli;
mod jocalsend;
use jocalsend::{Config, DEFAULT_INTERVAL, JocalService, JocalTasks, error::Result};
mod config;
mod discovery;
mod error;
mod http_server;
mod models;
mod transfer;
fn main() -> Result<()> {
// just in case we need to display the help
let _ = Cli::parse();
if std::env::var("RUST_LOG").is_err() {
unsafe {
std::env::set_var("RUST_LOG", "jocalsend");
}
}
init_logger(LevelFilter::Info).map_err(|e| std::io::Error::other(format!("{e}")))?;
tui_logger::set_env_filter_from_string(
&std::env::var("RUST_LOG").unwrap_or("jocalsend".to_string()),
);
set_env_filter_from_env(None);
let config = Config::new()?;
@ -51,7 +62,8 @@ async fn start_and_run(terminal: &mut DefaultTerminal, config: Config) -> Result
set_file_selection(&path, app.files());
}
let mut handles = app.service.start().await;
let mut handles = JoinSet::new();
app.service.start(&mut handles).await;
let shutdown = shutdown(&mut handles);
let mut shutdown = std::pin::pin!(shutdown);
loop {
@ -67,7 +79,7 @@ async fn start_and_run(terminal: &mut DefaultTerminal, config: Config) -> Result
} else {
app.handle_events().await?;
let peers = app.service.peers.read().await;
let peers = app.service.peers.lock().await;
app.peers.clear();
peers.iter().for_each(|(fingerprint, (addr, device))| {
let alias = device.alias.clone();
@ -87,14 +99,7 @@ async fn start_and_run(terminal: &mut DefaultTerminal, config: Config) -> Result
}
}
for id in stale_rx_requests {
if let Some(req) = app.receive_requests.get(&id) {
info!(
"Removing stale transfer request from {} for {}",
req.alias,
req.file_names().join(",")
);
app.receive_requests.remove(&id);
}
app.receive_requests.remove(&id);
}
}
}
@ -102,7 +107,7 @@ async fn start_and_run(terminal: &mut DefaultTerminal, config: Config) -> Result
Ok(())
}
async fn shutdown(handles: &mut JoinSet<JocalTask>) {
async fn shutdown(handles: &mut JoinSet<JocalTasks>) {
let mut timeout = tokio::time::interval(Duration::from_secs(5));
timeout.tick().await;
loop {

View file

@ -4,9 +4,9 @@ use chrono::{DateTime, Utc};
use julid::Julid;
use serde::{Deserialize, Serialize};
use crate::error::LocalSendError;
use crate::error::JocalError;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileMetadata {
pub id: String,
@ -21,7 +21,7 @@ pub struct FileMetadata {
pub metadata: Option<FileMetadataExt>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileMetadataExt {
#[serde(skip_serializing_if = "Option::is_none")]
pub modified: Option<String>,
@ -33,7 +33,7 @@ impl FileMetadata {
pub fn from_path(path: &Path) -> crate::error::Result<Self> {
let metadata = path.metadata()?;
if !metadata.is_file() {
return Err(LocalSendError::NotAFile);
return Err(JocalError::NotAFile);
}
let id = path.to_str().unwrap().to_string();

View file

@ -15,11 +15,11 @@ use tokio::sync::mpsc::{UnboundedSender, unbounded_channel};
use crate::{
JocalEvent, JocalService, Peers, ReceiveDialog, ReceiveRequest, SendingType, Sessions,
error::{LocalSendError, Result},
error::{JocalError, Result},
models::{Device, FileMetadata},
};
#[derive(Deserialize, Serialize, Clone)]
#[derive(Deserialize, Serialize)]
pub struct Session {
pub session_id: String,
pub files: BTreeMap<String, FileMetadata>,
@ -30,7 +30,7 @@ pub struct Session {
pub addr: SocketAddr,
}
#[derive(PartialEq, Deserialize, Serialize, Clone, Copy)]
#[derive(PartialEq, Deserialize, Serialize)]
pub enum SessionStatus {
Pending,
Active,
@ -65,10 +65,10 @@ impl JocalService {
}
pub async fn cancel_upload(&self, session_id: &str) -> Result<()> {
let sessions = self.sessions.read().await;
let sessions = self.sessions.lock().await;
let session = sessions
.get(session_id)
.ok_or(LocalSendError::SessionNotFound)?;
.ok_or(JocalError::SessionNotFound)?;
let request = self
.client
@ -80,7 +80,7 @@ impl JocalService {
.await?;
if request.status() != 200 {
return Err(LocalSendError::CancelFailed);
return Err(JocalError::CancelFailed);
}
Ok(())
@ -131,6 +131,7 @@ impl JocalService {
let token = match prepare_response.files.get(&metadata.id) {
Some(t) => t,
None => {
log::warn!("");
send_tx(
JocalEvent::SendFailed {
error: "missing token in prepare response from remote".into(),
@ -143,15 +144,6 @@ impl JocalService {
let content_id = &metadata.id;
let session_id = prepare_response.session_id;
log::info!(
"sending {content_id} to {}",
peers
.read()
.await
.get(&peer)
.map(|(_, peer)| peer.alias.as_str())
.unwrap_or("unknown peer")
);
let resp = do_send_bytes(sessions, client, &session_id, content_id, token, bytes).await;
match resp {
@ -237,7 +229,7 @@ pub async fn handle_prepare_upload(
service
.sessions
.write()
.lock()
.await
.insert(session_id.clone(), session);
@ -251,14 +243,6 @@ pub async fn handle_prepare_upload(
.into_response()
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UploadParams {
session_id: String,
file_id: String,
token: String,
}
pub async fn handle_receive_upload(
Query(params): Query<UploadParams>,
State(service): State<JocalService>,
@ -270,13 +254,10 @@ pub async fn handle_receive_upload(
let token = &params.token;
// Get session and validate
let session = {
let lock = service.sessions.read().await;
match lock.get(session_id).cloned() {
Some(session) => session,
None => return StatusCode::BAD_REQUEST.into_response(),
}
let mut sessions_lock = service.sessions.lock().await;
let session = match sessions_lock.get_mut(session_id) {
Some(session) => session,
None => return StatusCode::BAD_REQUEST.into_response(),
};
if session.status != SessionStatus::Active {
@ -292,7 +273,11 @@ pub async fn handle_receive_upload(
let file_metadata = match session.files.get(file_id) {
Some(metadata) => metadata,
None => {
return (StatusCode::BAD_REQUEST, "File not found".to_string()).into_response();
return (
StatusCode::INTERNAL_SERVER_ERROR,
"File not found".to_string(),
)
.into_response();
}
};
@ -300,8 +285,11 @@ pub async fn handle_receive_upload(
// Create directory if it doesn't exist
if let Err(e) = tokio::fs::create_dir_all(download_dir).await {
log::error!("could not create download directory '{download_dir:?}', got {e}");
return (StatusCode::INTERNAL_SERVER_ERROR, "could not save content").into_response();
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to create directory: {e}"),
)
.into_response();
}
// Create file path
@ -309,14 +297,13 @@ pub async fn handle_receive_upload(
// Write file
if let Err(e) = tokio::fs::write(&file_path, body).await {
log::warn!("could not save content to {file_path:?}, got {e}");
return (StatusCode::INTERNAL_SERVER_ERROR, "could not save content").into_response();
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to write file: {e}"),
)
.into_response();
}
log::info!(
"saved content from {} to {file_path:?}",
&session.sender.alias
);
if let Ok(id) = Julid::from_str(session_id) {
service.send_event(JocalEvent::ReceivedInbound(id));
};
@ -324,43 +311,41 @@ pub async fn handle_receive_upload(
StatusCode::OK.into_response()
}
// Query parameters struct
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CancelParams {
pub struct UploadParams {
session_id: String,
file_id: String,
token: String,
}
pub async fn handle_cancel(
Query(params): Query<CancelParams>,
State(service): State<JocalService>,
) -> impl IntoResponse {
// it's OK to hold this lock for the whole body here, we hardly ever have to
// handle cancels and none of these ops are slow.
let mut sessions_lock = service.sessions.write().await;
let mut sessions_lock = service.sessions.lock().await;
let session = match sessions_lock.get_mut(&params.session_id) {
Some(session) => session,
None => return StatusCode::BAD_REQUEST.into_response(),
};
info!(
"{} cancelled the transfer of {}",
&session.sender.alias,
session
.files
.values()
.map(|f| f.file_name.clone())
.collect::<Vec<_>>()
.join(",")
);
debug!("got cancel request for {}", params.session_id);
session.status = SessionStatus::Cancelled;
if let Ok(id) = Julid::from_str(&params.session_id) {
service.send_event(JocalEvent::Cancelled { session_id: id });
StatusCode::OK.into_response()
} else {
StatusCode::BAD_REQUEST.into_response()
}
};
StatusCode::OK.into_response()
}
// Cancel parameters struct
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CancelParams {
session_id: String,
}
// free function that can be called inside a future in tokio::task::spawn()
@ -369,22 +354,18 @@ async fn do_send_bytes(
client: Client,
session_id: &str,
content_id: &str,
token: &str,
token: &String,
body: Bytes,
) -> Result<()> {
let session = sessions
.read()
.await
.get(session_id)
.cloned()
.ok_or(LocalSendError::SessionNotFound)?;
let sessions = sessions.lock().await;
let session = sessions.get(session_id).unwrap();
if session.status != SessionStatus::Active {
return Err(LocalSendError::SessionInactive);
return Err(JocalError::SessionInactive);
}
if session.file_tokens.get(content_id).map(|t| t.as_str()) != Some(token) {
return Err(LocalSendError::InvalidToken);
if session.file_tokens.get(content_id) != Some(token) {
return Err(JocalError::InvalidToken);
}
let request = client
@ -397,11 +378,11 @@ async fn do_send_bytes(
let response = request.send().await?;
if response.status() != 200 {
log::warn!("non-200 remote response: {response:?}");
Err(LocalSendError::UploadFailed)
} else {
Ok(())
log::trace!("non-200 remote response: {response:?}");
return Err(JocalError::UploadFailed);
}
Ok(())
}
// free function that can be called inside a future in tokio::task::spawn()
@ -413,8 +394,8 @@ async fn do_prepare_upload(
sessions: &Sessions,
files: BTreeMap<String, FileMetadata>,
) -> Result<PrepareUploadResponse> {
let Some((addr, device)) = peers.read().await.get(peer).cloned() else {
return Err(LocalSendError::PeerNotFound);
let Some((addr, device)) = peers.lock().await.get(peer).cloned() else {
return Err(JocalError::PeerNotFound);
};
log::debug!("preparing upload request");
@ -440,7 +421,7 @@ async fn do_prepare_upload(
let response: PrepareUploadResponse = match response.json().await {
Err(e) => {
error!("got error deserializing response: {e:?}");
return Err(LocalSendError::RequestError(e));
return Err(JocalError::RequestError(e));
}
Ok(r) => r,
};
@ -458,7 +439,7 @@ async fn do_prepare_upload(
};
sessions
.write()
.lock()
.await
.insert(response.session_id.clone(), session);