Compare commits
1 commit
Author | SHA1 | Date | |
---|---|---|---|
|
e29295246f |
17 changed files with 856 additions and 1067 deletions
588
Cargo.lock
generated
588
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
11
Cargo.toml
11
Cargo.toml
|
@ -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"] }
|
||||
|
|
22
README.md
22
README.md
|
@ -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
|
||||
|
|
2
VERSION
2
VERSION
|
@ -1 +1 @@
|
|||
1.618033988
|
||||
1.61
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
404
src/app/mod.rs
404
src/app/mod.rs
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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(¤t_screen, &MAIN_MENU, area, buf);
|
||||
help_screen(area.inner(subscreen_margin), buf);
|
||||
}
|
||||
CurrentScreen::Logging | CurrentScreen::Stopping => {
|
||||
outer_frame(¤t_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);
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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!()
|
||||
}
|
||||
*/
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
39
src/main.rs
39
src/main.rs
|
@ -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 {
|
||||
|
|
|
@ -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();
|
||||
|
|
133
src/transfer.rs
133
src/transfer.rs
|
@ -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 = ¶ms.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(¶ms.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(¶ms.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);
|
||||
|
||||
|
|
Loading…
Reference in a new issue