diff --git a/CHANGELOG.md b/CHANGELOG.md index c231442..513ee95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **A send can be repeated from the History tab**, with A on its row. The log + now keeps the source paths of a send and the address it went to; the resend + dials the peer under that alias as the radar has it *now*, so a device that + changed address in between is still reached, and falls back to the recorded + address when it is off the radar. Files deleted since are left out — the rest + still go, and a toast says how many were missing. Sends of more than 64 files + are logged without their paths rather than growing `history.json` without + bound, and their rows offer no resend. +- **A history row can be dropped from the log**, with X. + ## [0.7.1] - 2026-08-24 ### Changed diff --git a/README.md b/README.md index c67355d..cf6273c 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,10 @@ or PC over Wi-Fi, no cable or SSH. Compatible with the official LocalSend apps. It targets [PortMaster-compatible](https://portmaster.games/supported-devices.html) Linux handhelds and the Miyoo Mini Plus and Flip running OnionOS or Allium, all of which are gamepad-only systems without a compositor. It also runs on regular desktop Linux and on Android handhelds and phones too. +

+ Three devices on a couch: a handheld showing the radar of nearby devices, a clamshell handheld waiting to receive, and a phone running the official LocalSend app +

+ | Receive | Request | Save | Transfer | |:---:|:---:|:---:|:---:| | ![Waiting for a sender, showing the device name, address, Wi-Fi network and the folder files land in](resources/retsend_receive.png) | ![An incoming request naming the sender, its files and where they land, counting down to the automatic decline](resources/retsend_request.png) | ![Picking the folder an incoming transfer lands in, the request still counting down](resources/retsend_save.png) | ![A transfer in progress: overall bar with bytes, speed and ETA, and a percentage per file](resources/retsend_transfer.png) | @@ -38,6 +42,7 @@ this is the missing end: a client built for a gamepad and screen. a manually typed IP address for the networks that block multicast. - **Receive** — accept/decline dialog with countdown, speed/ETA, and cancel from either side; X picks a folder for that one transfer; quick-save mode auto-accepts. - **Send** — gamepad file browser with multi-select and per-file progress; pin the folders and files you send often and they lead every listing. +- **History** — every transfer logged; A repeats a send from its row, X drops the row. - **Save routes** — received ROMs land in the console folder they belong to, detected from the card; per-extension overrides on top. - **Folders** — a folder sent from the official app arrives as a folder, its tree rebuilt under the save folder. - **Encryption** — the protocol's HTTPS mode, on by default; works with the official app's default settings both ways. @@ -124,9 +129,9 @@ hint along the bottom to press the button it names. | Pad | Keyboard | Action | |--------------|------------|-----------------------------------------------| | D-pad / stick| Arrows | Navigate · left/right switch tabs | -| A | Enter | Send to device · select file · accept · type | +| A | Enter | Send to device · select file · accept · repeat a send · type | | B | Esc | Back · decline · cancel · leave the keyboard | -| X | X / Bksp | Add a device by IP · pick where an incoming transfer lands · erase a character · take every file in the folder | +| X | X / Bksp | Add a device by IP · pick where an incoming transfer lands · delete a history row · erase a character · take every file in the folder | | Y | Y | Pin / unpin the row under the cursor | | Start | F1 | Confirm send · OK (keyboard) | | Select | Tab / F5 | Refresh radar · switch roots · layer (keyboard)| diff --git a/resources/retsend-devices.jpg b/resources/retsend-devices.jpg new file mode 100644 index 0000000..f850c16 Binary files /dev/null and b/resources/retsend-devices.jpg differ diff --git a/src/app/mod.rs b/src/app/mod.rs index 34d53f5..f157215 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -15,7 +15,7 @@ use crate::overlay::settings::SettingsRow; use crate::overlay::tabs::Tab; use crate::overlay::transfer::Viewed; use crate::overlay::Focus; -use crate::transfer::history::History; +use crate::transfer::history::{History, HistoryEntry}; use crate::transfer::outbound::{self, OutboundSession}; use crate::ui::AppUi; use std::path::PathBuf; @@ -325,6 +325,10 @@ impl App { .osk .open(OskTarget::PeerAddress, &local_subnet_prefix()); } + // X on the history: drop the row from the log. + (Focus::Tabs, AppCommand::Alt) if self.ui.tabs.active() == Tab::History => { + self.delete_history_entry(); + } // Nothing left to leave. Android's Back is a system button that has // to lead somewhere, so there it quits; on the handhelds the // launcher owns quitting and this stays inert. @@ -408,8 +412,8 @@ impl App { } } - /// A on a tab: Send opens the browser for the selected device, Settings - /// edits the current row, Receive does nothing. + /// A on a tab: Send opens the browser for the selected device, History + /// repeats the selected send, Settings edits the current row. fn tab_confirm(&mut self) { match self.ui.tabs.active() { Tab::Send => { @@ -418,11 +422,67 @@ impl App { } } Tab::Receive => {} - Tab::History => {} + Tab::History => self.resend_history_entry(), Tab::Settings => self.edit_setting(), } } + /// X on a History row: drop it from the log. The count is refreshed here + /// because the cursor is clamped against it before the next frame builds it. + fn delete_history_entry(&mut self) { + let Some(row) = self.ui.history.cursor(self.ui.history_count) else { + return; + }; + if self.history.remove(row).is_some() { + self.ui.history_count = self.history.entries().len(); + self.ui.toasts.push("Entry removed"); + } + } + + /// A on a History row: send the same files to the same peer again. + fn resend_history_entry(&mut self) { + let Some(row) = self.ui.history.cursor(self.ui.history_count) else { + return; + }; + let Some(entry) = self.history.get(row).cloned() else { + return; + }; + if !entry.resendable() { + self.ui.toasts.push("Only a send can be repeated"); + return; + } + let Some(base) = self.resend_base(&entry) else { + self.ui + .toasts + .push(format!("{} is not on the network", entry.peer)); + return; + }; + let (files, gone) = existing_files(&entry.files); + if files.is_empty() { + self.ui.toasts.push("Those files are gone"); + return; + } + if gone > 0 { + self.ui + .toasts + .push(format!("{gone} files are gone — sending the rest")); + } + self.start_send(entry.peer, base, files); + } + + /// Where a resend dials: the peer under that alias as the radar has it now + /// (its address may have moved since), else the recorded address. + fn resend_base(&self, entry: &HistoryEntry) -> Option { + self.net + .shared + .peers + .snapshot() + .iter() + .find(|p| p.info.alias == entry.peer) + .map(|p| p.base_url()) + .or_else(|| (!entry.peer_base.is_empty()).then(|| entry.peer_base.clone())) + } + /// A on a settings row: open its editor or toggle it. fn edit_setting(&mut self) { match self.ui.settings.row() { @@ -782,8 +842,18 @@ impl App { let files = self.ui.browser.selected_paths(); self.remember_send_dir(); self.ui.browser.close(); + self.start_send(target.alias, target.base, files); + } + + /// Start the worker for a send and take over the screen with it. Shared by + /// the browser's Start and the History tab's resend. + fn start_send(&mut self, alias: String, base: String, files: Vec) { + if self.outbound.as_ref().is_some_and(|o| !o.is_finished()) { + self.ui.toasts.push("A send is already running"); + return; + } let me = self.net.shared.me.lock().unwrap().clone(); - match outbound::spawn(target.alias, target.base, me, files, self.wake.clone()) { + match outbound::spawn(alias, base, me, files, self.wake.clone()) { Ok(session) => { self.net .shared @@ -823,6 +893,18 @@ impl App { } } +/// Recorded paths that are still files, and how many of them vanished — a +/// resend takes what is left rather than failing on the first missing one. +fn existing_files(paths: &[String]) -> (Vec, usize) { + let files: Vec = paths + .iter() + .map(PathBuf::from) + .filter(|p| p.is_file()) + .collect(); + let gone = paths.len() - files.len(); + (files, gone) +} + /// Vertical lists: up/down move the cursor; left/right are reserved for value /// steppers and do nothing in plain lists. fn nav_delta(dir: Direction) -> i32 { diff --git a/src/transfer/history.rs b/src/transfer/history.rs index f54da66..26fa56d 100644 --- a/src/transfer/history.rs +++ b/src/transfer/history.rs @@ -12,6 +12,10 @@ use std::sync::atomic::Ordering; /// Default cap on retained entries when the config omits `transfer.history_limit`. pub const DEFAULT_MAX_ENTRIES: usize = 200; +/// Cap on source paths kept for a resend; a bigger send is logged without them +/// rather than growing `history.json` without bound. +const MAX_RESEND_FILES: usize = 64; + #[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum Direction { Sent, @@ -45,9 +49,22 @@ pub struct HistoryEntry { /// Empty when nothing moved, or for entries written before this field. #[serde(default)] pub path: String, + /// Source paths of a send, for a resend. Empty on received entries, on + /// sends over [`MAX_RESEND_FILES`], and on entries predating this field. + #[serde(default)] + pub files: Vec, + /// `http://ip:port` the send went to — where a resend goes when the peer + /// is no longer on the radar. + #[serde(default)] + pub peer_base: String, } impl HistoryEntry { + /// Can this entry be sent again? + pub fn resendable(&self) -> bool { + self.direction == Direction::Sent && !self.files.is_empty() + } + pub fn from_inbound(s: &InboundSession) -> Self { let done = s.done_count(); let total = s.files.len(); @@ -70,6 +87,8 @@ impl HistoryEntry { .filter(|f| *f.state.lock().unwrap() == FileState::Done) .filter_map(|f| f.dest.parent()), ), + files: Vec::new(), + peer_base: String::new(), } } @@ -97,6 +116,17 @@ impl HistoryEntry { .filter(|f| *f.state.lock().unwrap() == FileState::Done) .filter_map(|f| f.path.parent()), ), + // A resend repeats the whole selection, so every file is kept — + // including the ones that failed, which is when it is worth most. + files: if s.files.len() <= MAX_RESEND_FILES { + s.files + .iter() + .map(|f| f.path.display().to_string()) + .collect() + } else { + Vec::new() + }, + peer_base: s.base.clone(), } } } @@ -143,14 +173,48 @@ impl History { } } - /// Append and persist. Best-effort write — a read-only SD degrades to an - /// in-memory log, not a crash. + /// Append and persist. pub fn record(&mut self, entry: HistoryEntry) { self.entries.push(entry); let overflow = self.entries.len().saturating_sub(self.limit); if overflow > 0 { self.entries.drain(..overflow); } + self.save(); + } + + /// Entries oldest-first (the renderer walks them newest-first). + pub fn entries(&self) -> &[HistoryEntry] { + &self.entries + } + + /// Entries newest-first — the order the History tab lists them, and what + /// its row indices count. + pub fn newest_first(&self) -> impl Iterator { + self.entries.iter().rev() + } + + /// The entry a History row shows. + pub fn get(&self, row: usize) -> Option<&HistoryEntry> { + self.entries.get(self.index_of(row)?) + } + + /// Drop the entry a History row shows and persist. + pub fn remove(&mut self, row: usize) -> Option { + let index = self.index_of(row)?; + let entry = self.entries.remove(index); + self.save(); + Some(entry) + } + + /// Storage index (oldest-first) of a newest-first row. + fn index_of(&self, row: usize) -> Option { + self.entries.len().checked_sub(row + 1) + } + + /// Best-effort write — a read-only SD degrades to an in-memory log, not a + /// crash. + fn save(&self) { match serde_json::to_string_pretty(&self.entries) { Ok(text) => { if let Err(e) = std::fs::write(&self.path, text) { @@ -160,11 +224,6 @@ impl History { Err(e) => log::warn!("could not serialize history: {e}"), } } - - /// Entries oldest-first (the renderer walks them newest-first). - pub fn entries(&self) -> &[HistoryEntry] { - &self.entries - } } fn now_unix() -> u64 { @@ -196,6 +255,8 @@ mod tests { outcome: Outcome::Completed, at: 0, path: "/save".to_string(), + files: vec!["/save/rom.gbc".to_string()], + peer_base: "http://10.0.0.2:53317".to_string(), } } @@ -214,6 +275,24 @@ mod tests { std::fs::remove_dir_all(dir.trim_end_matches('/')).unwrap(); } + #[test] + fn remove_takes_the_newest_first_row_and_persists() { + let dir = temp_dir("remove"); + { + let mut h = History::load(&dir, 10); + for i in 0..3 { + h.record(entry(&format!("p{i}"))); + } + // Row 0 is the newest ("p2"), row 2 the oldest. + assert_eq!(h.get(0).unwrap().peer, "p2"); + assert_eq!(h.remove(1).unwrap().peer, "p1"); + assert!(h.remove(2).is_none()); + assert_eq!(peers(&h), ["p0", "p2"]); + } + assert_eq!(peers(&History::load(&dir, 10)), ["p0", "p2"]); + std::fs::remove_dir_all(dir.trim_end_matches('/')).unwrap(); + } + #[test] fn inbound_entry_records_where_the_files_landed() { use crate::net::protocol::FileMeta; diff --git a/src/transfer/outbound.rs b/src/transfer/outbound.rs index 24421da..d0b1e43 100644 --- a/src/transfer/outbound.rs +++ b/src/transfer/outbound.rs @@ -36,6 +36,8 @@ pub struct OutboundFile { pub struct OutboundSession { pub peer_alias: String, + /// `http://ip:port` this send dials; the history keeps it for a resend. + pub base: String, pub files: Vec, pub total_bytes: u64, pub sent_total: AtomicU64, @@ -116,6 +118,7 @@ pub fn spawn( let session = Arc::new(OutboundSession { peer_alias, + base, files, total_bytes: total, sent_total: AtomicU64::new(0), @@ -127,13 +130,14 @@ pub fn spawn( let worker = session.clone(); std::thread::Builder::new() .name("outbound".into()) - .spawn(move || run(worker, base, me, wake))?; + .spawn(move || run(worker, me, wake))?; Ok(session) } -fn run(session: Arc, base: String, me: DeviceInfo, wake: Arc) { +fn run(session: Arc, me: DeviceInfo, wake: Arc) { + let base = &session.base; let metas: Vec = session.files.iter().map(|f| f.meta.clone()).collect(); - let response = match client::prepare_upload(&base, &me, &metas) { + let response = match client::prepare_upload(base, &me, &metas) { Ok(r) => r, Err(e) => { let phase = match e { @@ -152,7 +156,7 @@ fn run(session: Arc, base: String, me: DeviceInfo, wake: Arc, base: String, me: DeviceInfo, wake: Arc, base: String, me: DeviceInfo, wake: Arc, pub cursor: Option, + /// The row under the cursor is a send whose files are still recorded, so + /// the resend hint is offered. + pub can_resend: bool, } /// Build a row from an entry, resolving the relative time against `now` @@ -56,7 +60,14 @@ pub fn row(e: &HistoryEntry, now: u64) -> HistoryRow { pub fn render(root: &mut egui::Ui, data: &HistoryData, taps: &mut Vec) { egui::Panel::bottom(super::BOTTOM_PANEL_ID).show(root, |ui| { ui.add_space(4.0); - super::home::hint_bar(ui, &[("← →", "Tabs", None)], taps); + let mut hints: Vec = vec![("← →", "Tabs", None)]; + if !data.rows.is_empty() { + hints.push(("X", "Delete", Some(AppCommand::Alt))); + } + if data.can_resend { + hints.push(("A", "Send again", Some(AppCommand::Confirm))); + } + super::home::hint_bar(ui, &hints, taps); ui.add_space(4.0); }); @@ -101,7 +112,8 @@ pub fn render(root: &mut egui::Ui, data: &HistoryData, taps: &mut Vec viewport.max.y { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index d43e2cf..aeb0cbe 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -326,12 +326,14 @@ impl AppUi { Tab::History => { self.history_count = history.entries().len(); let now = unix_now(); + let cursor = self.history.cursor(self.history_count); Screen::History(history::HistoryData { - cursor: self.history.cursor(self.history_count), + cursor, + can_resend: cursor + .and_then(|row| history.get(row)) + .is_some_and(|e| e.resendable()), rows: history - .entries() - .iter() - .rev() + .newest_first() .map(|e| history::row(e, now)) .collect(), }) diff --git a/tests/send.rs b/tests/send.rs index 49fe68d..1b2a370 100644 --- a/tests/send.rs +++ b/tests/send.rs @@ -4,6 +4,7 @@ use retsend::net::discovery::PeerRegistry; use retsend::net::protocol::{self, DeviceInfo}; use retsend::net::{server, NetShared, TransferSettings, Wake, WakeReason}; +use retsend::transfer::history::HistoryEntry; use retsend::transfer::outbound::{self, OutboundPhase}; use std::net::TcpStream; use std::path::PathBuf; @@ -93,7 +94,7 @@ fn sends_files_end_to_end() { let session = outbound::spawn( "Receiver".into(), - base, + base.clone(), device("Sender"), vec![src.join("game.gbc"), src.join("save.dat")], Arc::new(NoopWake), @@ -112,6 +113,18 @@ fn sends_files_end_to_end() { ); assert_eq!(std::fs::read(save_dir.join("save.dat")).unwrap(), b"SAVE"); + // What the History tab needs to repeat this send. + let entry = HistoryEntry::from_outbound(&session); + assert!(entry.resendable()); + assert_eq!( + entry.files, + [ + src.join("game.gbc").display().to_string(), + src.join("save.dat").display().to_string() + ] + ); + assert_eq!(entry.peer_base, base); + std::fs::remove_dir_all(&src).unwrap(); stop(); }