Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<p align="center">
<img src="resources/retsend-devices.jpg" alt="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" width="480">
</p>

| 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) |
Expand All @@ -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.
Expand Down Expand Up @@ -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)|
Expand Down
Binary file added resources/retsend-devices.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
92 changes: 87 additions & 5 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 => {
Expand All @@ -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<String> {
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() {
Expand Down Expand Up @@ -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<PathBuf>) {
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
Expand Down Expand Up @@ -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<PathBuf>, usize) {
let files: Vec<PathBuf> = 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 {
Expand Down
93 changes: 86 additions & 7 deletions src/transfer/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<String>,
/// `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();
Expand All @@ -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(),
}
}

Expand Down Expand Up @@ -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(),
}
}
}
Expand Down Expand Up @@ -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<Item = &HistoryEntry> {
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<HistoryEntry> {
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<usize> {
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) {
Expand All @@ -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 {
Expand Down Expand Up @@ -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(),
}
}

Expand All @@ -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;
Expand Down
Loading
Loading