diff --git a/crates/vnidrop/src/runtime/contacts.rs b/crates/vnidrop/src/runtime/contacts.rs index f0d1128..7b2664e 100644 --- a/crates/vnidrop/src/runtime/contacts.rs +++ b/crates/vnidrop/src/runtime/contacts.rs @@ -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, + transfer_id: u64, + endpoint_id: String, + ) -> Result { + 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, + endpoint_id: String, + grant: HeldGrant, + share: ShareResult, + sender_name: Option<&str>, + ) -> Result { + 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, diff --git a/crates/vnidrop/src/runtime/facade.rs b/crates/vnidrop/src/runtime/facade.rs index 58440f8..34dd0d0 100644 --- a/crates/vnidrop/src/runtime/facade.rs +++ b/crates/vnidrop/src/runtime/facade.rs @@ -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 { + 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, VnidropError> { self.block_on(self.inner.list_held_offers()) diff --git a/crates/vnidrop/tests/offer.rs b/crates/vnidrop/tests/offer.rs index 24b8ff9..95076f6 100644 --- a/crates/vnidrop/tests/offer.rs +++ b/crates/vnidrop/tests/offer.rs @@ -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()); +}