feat(core): offer an existing share to a remembered device

Another way to deliver an invitation the user already created, alongside the
QR code, rather than a second share of the same files: the ticket handed over
is the stored one and the transfer id is unchanged.

Only an active share can be offered. A stopped one no longer serves its
content, so handing out its ticket would promise nothing.
This commit is contained in:
2026-08-07 10:14:53 +02:00
parent cba51ad504
commit d8587851a3
3 changed files with 178 additions and 3 deletions

View File

@@ -23,6 +23,7 @@ use crate::{
DeliverGrant, GrantDeliveryResponse, OfferResponse, OfferService, RevokeGrant, SubmitOffer,
},
ticket::{encode_persisted_sender_address, parse_persisted_sender_address},
transfer_state::{TransferDirection, TransferStatus},
util::now_ms,
};
@@ -179,17 +180,87 @@ impl CoreInner {
metadata.access_mode = TransferAccessMode::ApprovalRequired;
let sender_name = metadata.sender_name.clone();
let share = self.share_files(sources, metadata).await?;
self.offer_share(endpoint_id, grant, share, sender_name.as_deref())
.await
}
/// Offer a share that already exists, so a transfer created for an
/// invitation can also be pushed to a remembered device.
///
/// The ticket is the one already stored for the transfer: this adds another
/// way to deliver it, it does not create a second share of the same files.
pub(super) async fn offer_transfer_to_contact(
self: &Arc<Self>,
transfer_id: u64,
endpoint_id: String,
) -> Result<ContactSendResult> {
let grant = self
.repository
.contacts()
.held_grant_for(&endpoint_id)
.await
.map_err(VnidropError::repository)?
.ok_or_else(|| {
VnidropError::permission(anyhow::anyhow!(
"no live grant for this device; pair with it again"
))
})?;
let stored = self
.repository
.list_transfers()
.await
.map_err(VnidropError::repository)?
.into_iter()
.find(|transfer| transfer.transfer_id == transfer_id)
.ok_or_else(|| {
VnidropError::invalid_input(anyhow::anyhow!("unknown transfer {transfer_id}"))
})?;
// Only a live share can be offered: a stopped one no longer serves its
// content, so handing out its ticket would promise nothing.
if stored.direction != TransferDirection::Send.as_str()
|| stored.status != TransferStatus::Sharing.as_str()
{
return Err(VnidropError::invalid_input(anyhow::anyhow!(
"transfer {transfer_id} is not an active share"
))
.into());
}
let ticket = stored.ticket.clone().ok_or_else(|| {
VnidropError::invalid_input(anyhow::anyhow!("transfer {transfer_id} has no invitation"))
})?;
let share = ShareResult {
transfer_id,
ticket,
hash: stored.content_hash.unwrap_or_default(),
transfer_name: stored.transfer_name.unwrap_or_default(),
file_count: stored.file_count,
total_size: stored.total_size,
};
self.offer_share(endpoint_id, grant, share, None).await
}
/// Deliver an offer for `share`, holding it when the device is not running.
async fn offer_share(
self: &Arc<Self>,
endpoint_id: String,
grant: HeldGrant,
share: ShareResult,
sender_name: Option<&str>,
) -> Result<ContactSendResult> {
let store = self.repository.contacts();
// An unreachable device is the common case on mobile, not an error: the
// share stays here and the ticket waits for the peer to come and get it.
let outcome = match self
.deliver_offer(&endpoint_id, &grant, &share, sender_name.as_deref())
.deliver_offer(&endpoint_id, &grant, &share, sender_name)
.await
{
Ok(outcome) => outcome,
Err(error) => {
self.hold_offer(&endpoint_id, &share, sender_name.as_deref())
.await?;
self.hold_offer(&endpoint_id, &share, sender_name).await?;
tracing::debug!(%error, "offer held for later pickup");
return Ok(ContactSendResult {
share,

View File

@@ -302,6 +302,22 @@ impl VnidropCore {
.map_err(VnidropError::transfer)
}
/// Offer an existing share to a remembered device.
///
/// Another way to deliver the invitation already created for a transfer,
/// alongside the QR code — not a second share of the same files.
pub fn offer_transfer_to_contact(
&self,
transfer_id: u64,
endpoint_id: String,
) -> Result<ContactSendResult, VnidropError> {
self.block_on(
self.inner
.offer_transfer_to_contact(transfer_id, endpoint_id),
)
.map_err(VnidropError::transfer)
}
/// Transfers this device is holding for contacts that were not running.
pub fn list_held_offers(&self) -> Result<Vec<HeldOfferSummary>, VnidropError> {
self.block_on(self.inner.list_held_offers())

View File

@@ -561,3 +561,91 @@ fn polling_a_device_that_holds_nothing_for_you_returns_nothing() {
assert_eq!(receiver.core.poll_contacts_for_offers().unwrap(), 0);
assert!(receiver.core.list_pending_offers().is_empty());
}
/// A transfer created for an invitation can also be pushed to a device: the
/// same ticket, another way to deliver it.
#[test]
fn an_existing_share_can_be_offered_to_a_contact() {
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("shared.txt");
std::fs::write(&source_path, b"existing share").unwrap();
let output_dir = tempfile::tempdir().unwrap();
let sender = TestNode::new();
let receiver = TestNode::new();
pair(&receiver, &sender);
// An ordinary share, as if the user had created it for a QR code.
let share = sender
.core
.share_files(sources(&source_path), metadata(6_001))
.expect("shared");
let core = sender.core.arc();
let to = endpoint_id(&receiver);
let handle = std::thread::spawn(move || core.offer_transfer_to_contact(share.transfer_id, to));
let offer = wait_for_offer(&receiver.core);
let ticket = receiver
.core
.respond_to_offer(offer.offer_id, true)
.expect("accepting yields the ticket");
let outcome = handle.join().unwrap().expect("offer accepted");
assert!(outcome.delivered);
assert_eq!(
outcome.share.transfer_id, share.transfer_id,
"offering reuses the existing transfer rather than creating another"
);
assert_eq!(ticket, share.ticket, "the invitation is the stored one");
receiver
.core
.receive(
ticket,
output_dir.path().to_string_lossy().to_string(),
Some("Receiver".to_string()),
)
.expect("receive completes");
assert_eq!(
std::fs::read(output_dir.path().join("shared.txt")).unwrap(),
b"existing share"
);
}
/// A stopped share serves nothing, so its ticket must not be handed out.
#[test]
fn a_stopped_share_cannot_be_offered() {
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("shared.txt");
std::fs::write(&source_path, b"content").unwrap();
let sender = TestNode::new();
let receiver = TestNode::new();
pair(&receiver, &sender);
let share = sender
.core
.share_files(sources(&source_path), metadata(6_002))
.expect("shared");
sender.core.cancel_transfer(share.transfer_id).unwrap();
let outcome = sender
.core
.offer_transfer_to_contact(share.transfer_id, endpoint_id(&receiver));
assert!(outcome.is_err());
assert!(receiver.core.list_pending_offers().is_empty());
}
/// Offering an unknown transfer is rejected rather than silently doing nothing.
#[test]
fn offering_an_unknown_transfer_is_rejected() {
let sender = TestNode::new();
let receiver = TestNode::new();
pair(&receiver, &sender);
assert!(sender
.core
.offer_transfer_to_contact(9_999, endpoint_id(&receiver))
.is_err());
}