5 Commits

Author SHA1 Message Date
Hammed Abass
5939489432 Merge pull request #29 from sudosylabs/feat/win32-screenshots
fix(desktop): use native Windows file and folder dialogs
2026-07-22 22:06:20 +02:00
Hammed Abass
90a06e9151 Merge pull request #28 from sudosylabs/fix/transfer-completion-progress
fix(transfer): finalize delivery completion
2026-07-22 21:54:57 +02:00
6cf6644c09 fix(desktop): use native Windows file dialogs 2026-07-22 21:49:49 +02:00
f0b06ad1cf fix(transfer): finalize delivery completion 2026-07-22 21:34:33 +02:00
e7c70c9314 docs: add Microsoft Store screenshots 2026-07-22 21:21:58 +02:00
30 changed files with 834 additions and 100 deletions

View File

@@ -44,6 +44,24 @@ final class ProgressDerivationTests: XCTestCase {
XCTAssertEqual(progress?.progress, 0.3) XCTAssertEqual(progress?.progress, 0.3)
} }
func testReceiverCompletionAfterProgressIsTerminal() {
let events = [
receiverEvent(kind: "completed", json: "{\"connection_id\":1,\"request_id\":1,\"endpoint_id\":\"peer-a\"}"),
receiverEvent(kind: "progress", json: "{\"connection_id\":1,\"request_id\":1,\"endpoint_id\":\"peer-a\",\"end_offset\":100}"),
receiverEvent(kind: "started", json: "{\"connection_id\":1,\"request_id\":1,\"endpoint_id\":\"peer-a\",\"size\":100}"),
]
let progress = progressForReceiver(
events: events,
transferId: 1,
remoteEndpointId: "peer-a",
totalSizeHint: 100
)
XCTAssertEqual(progress?.kind, "completed")
XCTAssertEqual(progress?.labelKey, "progress_completed")
XCTAssertEqual(progress?.progress, 1)
}
func testStatusLabelKeys() { func testStatusLabelKeys() {
XCTAssertEqual(statusLabelKey(.sharing), "status_available") XCTAssertEqual(statusLabelKey(.sharing), "status_available")
XCTAssertEqual(statusLabelKey(.receiving), "status_receiving") XCTAssertEqual(statusLabelKey(.receiving), "status_receiving")
@@ -56,4 +74,8 @@ final class ProgressDerivationTests: XCTestCase {
direction: "send", phase: phase, kind: kind, dataJson: json direction: "send", phase: phase, kind: kind, dataJson: json
) )
} }
private func receiverEvent(kind: String, json: String) -> CoreEventModel {
event(phase: "transfer", kind: kind, json: json)
}
} }

View File

@@ -197,7 +197,7 @@ final class CoreRepository: ObservableObject, CoreGateway {
guard let transferId = model.transferId else { return } guard let transferId = model.transferId else { return }
switch model.phase { switch model.phase {
case "approval": signalsSubject.send(.approvalChanged(transferId: transferId)) case "approval", "access": signalsSubject.send(.approvalChanged(transferId: transferId))
case "delivery": signalsSubject.send(.receiverHistoryChanged(transferId: transferId)) case "delivery": signalsSubject.send(.receiverHistoryChanged(transferId: transferId))
default: break default: break
} }

View File

@@ -96,14 +96,14 @@ func progressForReceiver(
labelKey: "progress_interrupted", progress: nil, detail: nil labelKey: "progress_interrupted", progress: nil, detail: nil
) )
} }
if latest.kind == "completed" && !transferEvents.contains(where: { $0.kind == "progress" || $0.kind == "started" }) { let progress = aggregateReceiverProgress(events: transferEvents, totalSizeHint: totalSizeHint)
if latest.kind == "completed" && (progress.map { $0 >= 0.999 } ?? true) {
return TransferProgress( return TransferProgress(
transferId: transferId, phase: "transfer", kind: "completed", transferId: transferId, phase: "transfer", kind: "completed",
labelKey: "progress_completed", progress: 1, detail: nil labelKey: "progress_completed", progress: 1, detail: nil
) )
} }
let progress = aggregateReceiverProgress(events: transferEvents, totalSizeHint: totalSizeHint)
return TransferProgress( return TransferProgress(
transferId: transferId, phase: "transfer", kind: latest.kind, transferId: transferId, phase: "transfer", kind: latest.kind,
labelKey: "progress_sending", progress: progress, detail: progressDetail(latest) labelKey: "progress_sending", progress: progress, detail: progressDetail(latest)

View File

@@ -127,11 +127,14 @@ struct SendScreen: View {
private func sharingProgress(for transfer: Transfer) -> TransferProgress? { private func sharingProgress(for transfer: Transfer) -> TransferProgress? {
let active = (model.receiversByTransfer[transfer.transferId] ?? []).filter { $0.status == .accepted } let active = (model.receiversByTransfer[transfer.transferId] ?? []).filter { $0.status == .accepted }
if active.isEmpty { return nil } if active.isEmpty { return nil }
let fractions = active.compactMap { let fractions: [Double] = active.compactMap { receiver -> Double? in
progressForReceiver(events: model.coreState.events, transferId: transfer.transferId, let progress = progressForReceiver(events: model.coreState.events, transferId: transfer.transferId,
remoteEndpointId: $0.remoteEndpointId, totalSizeHint: transfer.totalSize)?.progress remoteEndpointId: receiver.remoteEndpointId, totalSizeHint: transfer.totalSize)
guard progress?.kind == "started" || progress?.kind == "progress" else { return nil }
return progress?.progress
} }
let combined = fractions.isEmpty ? nil : fractions.reduce(0, +) / Double(fractions.count) guard !fractions.isEmpty else { return nil }
let combined = fractions.reduce(0, +) / Double(fractions.count)
if active.count == 1 { if active.count == 1 {
return TransferProgress(transferId: transfer.transferId, phase: "transfer", kind: "progress", return TransferProgress(transferId: transfer.transferId, phase: "transfer", kind: "progress",
labelKey: "progress_sending", progress: combined) labelKey: "progress_sending", progress: combined)

View File

@@ -0,0 +1,9 @@
# Microsoft Store screenshot order and captions
1. **Review transfer** — Choose a file, name the transfer, and decide whether every receiver needs your approval.
2. **Share with QR** — Invite another device with a QR code or a portable `.vnd` invitation file.
3. **Transfer details** — See availability, size, access policy, receiver activity, and sharing options in one place.
4. **Receive invitation** — Open a private VniDrop invitation to receive files directly on your PC.
5. **Privacy and security** — Direct, account-free, end-to-end encrypted transfer with no cloud copy left behind.
All final screenshots are 1920 × 1080 PNG files. The QR shown in screenshot 2 is a deliberately non-functional demo pattern; it does not contain a live transfer capability.

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

View File

@@ -20,7 +20,7 @@ use crate::{
util::now_ms, util::now_ms,
}; };
const SCHEMA_VERSION: i64 = 5; const SCHEMA_VERSION: i64 = 6;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct Repository { pub(crate) struct Repository {
@@ -78,6 +78,23 @@ pub(crate) struct ReceiverRequestInsert<'a> {
pub(crate) app_version: &'a str, pub(crate) app_version: &'a str,
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PendingDeliveryReceipt {
pub(crate) local_transfer_id: u64,
pub(crate) sender_blob_ticket: String,
pub(crate) request_id: String,
pub(crate) sender_transfer_id: u64,
pub(crate) token: String,
}
pub(crate) struct PendingDeliveryReceiptInsert<'a> {
pub(crate) local_transfer_id: u64,
pub(crate) sender_blob_ticket: &'a str,
pub(crate) request_id: &'a str,
pub(crate) sender_transfer_id: u64,
pub(crate) token: &'a str,
}
impl Repository { impl Repository {
pub(crate) async fn open(app_data_dir: &Path) -> Result<Self> { pub(crate) async fn open(app_data_dir: &Path) -> Result<Self> {
let db_path = app_data_dir.join("vnidrop.sqlite3"); let db_path = app_data_dir.join("vnidrop.sqlite3");
@@ -268,6 +285,21 @@ impl Repository {
.execute(&self.pool) .execute(&self.pool)
.await?; .await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS pending_delivery_receipts (
request_id TEXT PRIMARY KEY,
local_transfer_id INTEGER NOT NULL,
sender_blob_ticket TEXT NOT NULL,
sender_transfer_id INTEGER NOT NULL,
token TEXT NOT NULL,
created_at INTEGER NOT NULL
);
"#,
)
.execute(&self.pool)
.await?;
sqlx::query(&format!("PRAGMA user_version = {SCHEMA_VERSION}")) sqlx::query(&format!("PRAGMA user_version = {SCHEMA_VERSION}"))
.execute(&self.pool) .execute(&self.pool)
.await?; .await?;
@@ -506,6 +538,82 @@ impl Repository {
Ok(()) Ok(())
} }
pub(crate) async fn complete_receive_with_pending_receipt(
&self,
receipt: PendingDeliveryReceiptInsert<'_>,
) -> Result<()> {
self.maybe_fail_write()?;
let mut transaction = self.pool.begin().await?;
let updated = sqlx::query(
r#"
UPDATE transfers
SET status = 'done', updated_at = ?1
WHERE transfer_id = ?2 AND direction = 'receive' AND status = 'receiving'
"#,
)
.bind(now_ms())
.bind(to_db_id(receipt.local_transfer_id)?)
.execute(&mut *transaction)
.await?;
require_one_changed(updated.rows_affected(), "complete receive")?;
sqlx::query(
r#"
INSERT INTO pending_delivery_receipts (
request_id, local_transfer_id, sender_blob_ticket,
sender_transfer_id, token, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(request_id) DO UPDATE SET
local_transfer_id = excluded.local_transfer_id,
sender_blob_ticket = excluded.sender_blob_ticket,
sender_transfer_id = excluded.sender_transfer_id,
token = excluded.token
"#,
)
.bind(receipt.request_id)
.bind(to_db_id(receipt.local_transfer_id)?)
.bind(receipt.sender_blob_ticket)
.bind(to_db_id(receipt.sender_transfer_id)?)
.bind(receipt.token)
.bind(now_ms())
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
Ok(())
}
pub(crate) async fn list_pending_delivery_receipts(
&self,
) -> Result<Vec<PendingDeliveryReceipt>> {
let rows = sqlx::query(
r#"
SELECT local_transfer_id, sender_blob_ticket, request_id,
sender_transfer_id, token
FROM pending_delivery_receipts
ORDER BY created_at ASC
"#,
)
.fetch_all(&self.pool)
.await?;
Ok(rows
.into_iter()
.map(|row| PendingDeliveryReceipt {
local_transfer_id: row.get::<i64, _>("local_transfer_id") as u64,
sender_blob_ticket: row.get("sender_blob_ticket"),
request_id: row.get("request_id"),
sender_transfer_id: row.get::<i64, _>("sender_transfer_id") as u64,
token: row.get("token"),
})
.collect())
}
pub(crate) async fn delete_pending_delivery_receipt(&self, request_id: &str) -> Result<()> {
sqlx::query("DELETE FROM pending_delivery_receipts WHERE request_id = ?1")
.bind(request_id)
.execute(&self.pool)
.await?;
Ok(())
}
pub(crate) async fn update_active_share_access_mode( pub(crate) async fn update_active_share_access_mode(
&self, &self,
transfer_id: u64, transfer_id: u64,

View File

@@ -0,0 +1,135 @@
use std::{str::FromStr, sync::Arc, time::Duration};
use iroh_blobs::ticket::BlobTicket;
use serde_json::json;
use super::CoreInner;
use crate::{
handshake::{DeliveryReceipt, DeliveryReceiptResponse, HandshakeService},
repository::PendingDeliveryReceipt,
};
const DELIVERY_RECEIPT_TIMEOUT: Duration = Duration::from_secs(5);
const DELIVERY_RECEIPT_RETRY_INTERVAL: Duration = Duration::from_secs(5);
const DELIVERY_RECEIPT_MAX_RETRY_INTERVAL: Duration = Duration::from_secs(5 * 60);
impl CoreInner {
pub(super) async fn spawn_delivery_receipt_task(self: &Arc<Self>) {
let core = Arc::downgrade(self);
let task = tokio::spawn(async move {
let mut retry_interval = DELIVERY_RECEIPT_RETRY_INTERVAL;
loop {
let Some(core) = core.upgrade() else {
break;
};
let has_pending = core.deliver_pending_receipts().await;
if has_pending {
let notified = tokio::select! {
() = core.delivery_receipt_notify.notified() => true,
() = tokio::time::sleep(retry_interval) => false,
};
if notified {
retry_interval = DELIVERY_RECEIPT_RETRY_INTERVAL;
} else {
retry_interval = retry_interval
.saturating_mul(2)
.min(DELIVERY_RECEIPT_MAX_RETRY_INTERVAL);
}
} else {
core.delivery_receipt_notify.notified().await;
retry_interval = DELIVERY_RECEIPT_RETRY_INTERVAL;
}
}
});
*self.delivery_receipt_task.lock().await = Some(task);
}
async fn deliver_pending_receipts(&self) -> bool {
let receipts = match self.repository.list_pending_delivery_receipts().await {
Ok(receipts) => receipts,
Err(error) => {
tracing::warn!(%error, "failed to load pending delivery receipts");
return true;
}
};
let has_pending = !receipts.is_empty();
for receipt in receipts {
self.deliver_pending_receipt(receipt).await;
}
has_pending
}
async fn deliver_pending_receipt(&self, pending: PendingDeliveryReceipt) {
let blob_ticket = match BlobTicket::from_str(&pending.sender_blob_ticket) {
Ok(ticket) => ticket,
Err(error) => {
tracing::warn!(%error, request_id = %pending.request_id, "discarded invalid pending delivery receipt");
let _ = self
.repository
.delete_pending_delivery_receipt(&pending.request_id)
.await;
self.emit_transfer(
pending.local_transfer_id,
"receive",
"delivery",
"receipt-rejected",
json!({ "reason": "invalid-sender-ticket" }),
);
return;
}
};
let client = HandshakeService::client(self.endpoint.clone(), blob_ticket.addr().clone());
let receipt = DeliveryReceipt {
request_id: pending.request_id.clone(),
transfer_id: pending.sender_transfer_id,
token: pending.token,
};
match tokio::time::timeout(DELIVERY_RECEIPT_TIMEOUT, client.report_delivery(receipt)).await
{
Ok(Ok(DeliveryReceiptResponse::Recorded)) => {
if let Err(error) = self
.repository
.delete_pending_delivery_receipt(&pending.request_id)
.await
{
tracing::warn!(%error, request_id = %pending.request_id, "failed to clear recorded delivery receipt");
return;
}
self.emit_transfer(
pending.local_transfer_id,
"receive",
"delivery",
"receipt-recorded",
json!({ "sender_transfer_id": pending.sender_transfer_id }),
);
}
Ok(Ok(DeliveryReceiptResponse::Rejected { reason })) => {
let _ = self
.repository
.delete_pending_delivery_receipt(&pending.request_id)
.await;
self.emit_transfer(
pending.local_transfer_id,
"receive",
"delivery",
"receipt-rejected",
json!({ "reason": reason }),
);
}
Ok(Err(error)) => self.emit_transfer(
pending.local_transfer_id,
"receive",
"delivery",
"receipt-failed",
json!({ "reason": error.to_string() }),
),
Err(_) => self.emit_transfer(
pending.local_transfer_id,
"receive",
"delivery",
"receipt-failed",
json!({ "reason": "delivery receipt timed out" }),
),
}
}
}

View File

@@ -187,6 +187,10 @@ impl CoreInner {
// Flush before stopping the router so the app can show the shutdown // Flush before stopping the router so the app can show the shutdown
// event even if the process exits soon after Compose disposes the core. // event even if the process exits soon after Compose disposes the core.
self.event_hub.flush().await; self.event_hub.flush().await;
if let Some(task) = self.delivery_receipt_task.lock().await.take() {
task.abort();
let _ = task.await;
}
if let Err(error) = self.router.shutdown().await { if let Err(error) = self.router.shutdown().await {
self.emit_endpoint( self.emit_endpoint(
"shutdown", "shutdown",

View File

@@ -7,6 +7,7 @@
//! - [`lifecycle`] — cancel/delete/shutdown/status/access //! - [`lifecycle`] — cancel/delete/shutdown/status/access
//! - [`provider`] — blob provider events and per-connection send progress //! - [`provider`] — blob provider events and per-connection send progress
mod delivery;
mod facade; mod facade;
mod lifecycle; mod lifecycle;
mod provider; mod provider;
@@ -38,7 +39,7 @@ use iroh_blobs::{
}; };
use serde_json::json; use serde_json::json;
use tokio::{ use tokio::{
sync::{oneshot, Mutex as TokioMutex, Semaphore}, sync::{oneshot, Mutex as TokioMutex, Notify, Semaphore},
task::JoinHandle, task::JoinHandle,
}; };
@@ -77,6 +78,8 @@ pub(super) struct CoreInner {
pub(super) hash_to_transfer: TokioMutex<HashMap<String, HashSet<u64>>>, pub(super) hash_to_transfer: TokioMutex<HashMap<String, HashSet<u64>>>,
pub(super) connection_endpoints: TokioMutex<HashMap<u64, String>>, pub(super) connection_endpoints: TokioMutex<HashMap<u64, String>>,
pub(super) provider_task: TokioMutex<Option<JoinHandle<()>>>, pub(super) provider_task: TokioMutex<Option<JoinHandle<()>>>,
pub(super) delivery_receipt_notify: Notify,
pub(super) delivery_receipt_task: TokioMutex<Option<JoinHandle<()>>>,
pub(super) shutdown_started: AtomicBool, pub(super) shutdown_started: AtomicBool,
} }
@@ -244,6 +247,8 @@ impl CoreInner {
hash_to_transfer: TokioMutex::new(restored_hashes), hash_to_transfer: TokioMutex::new(restored_hashes),
connection_endpoints: TokioMutex::new(HashMap::new()), connection_endpoints: TokioMutex::new(HashMap::new()),
provider_task: TokioMutex::new(None), provider_task: TokioMutex::new(None),
delivery_receipt_notify: Notify::new(),
delivery_receipt_task: TokioMutex::new(None),
shutdown_started: AtomicBool::new(false), shutdown_started: AtomicBool::new(false),
}); });
@@ -257,6 +262,7 @@ impl CoreInner {
}), }),
); );
inner.spawn_provider_event_task(event_rx).await; inner.spawn_provider_event_task(event_rx).await;
inner.spawn_delivery_receipt_task().await;
Ok(inner) Ok(inner)
} }

View File

@@ -26,8 +26,8 @@ use crate::{
validated_relative_string, wait_for_writer, write_stream_to_blocking_writer, validated_relative_string, wait_for_writer, write_stream_to_blocking_writer,
AtomicOutputFile, AtomicOutputFile,
}, },
handshake::{DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse, HandshakeService}, handshake::{DeliveryReceipt, HandshakeResponse, HandshakeService},
repository::{ReceivedArtifactInsert, TransferUpsert}, repository::{PendingDeliveryReceiptInsert, ReceivedArtifactInsert, TransferUpsert},
ticket::{parse_transfer_ticket_with_limits, ParsedTransferTicket}, ticket::{parse_transfer_ticket_with_limits, ParsedTransferTicket},
transfer_state::{TransferDirection, TransferStatus}, transfer_state::{TransferDirection, TransferStatus},
}; };
@@ -271,6 +271,7 @@ impl CoreInner {
.map_err(VnidropError::filesystem)?; .map_err(VnidropError::filesystem)?;
} }
let sender_addr = parsed.blob_ticket.addr().clone(); let sender_addr = parsed.blob_ticket.addr().clone();
let sender_blob_ticket = parsed.blob_ticket.to_string();
self.emit_transfer(transfer_id, "receive", "network", "connecting", json!({})); self.emit_transfer(transfer_id, "receive", "network", "connecting", json!({}));
// Every VniDrop ticket carries metadata and must complete the handshake. // Every VniDrop ticket carries metadata and must complete the handshake.
@@ -352,40 +353,18 @@ impl CoreInner {
self.export_collection(transfer_id, total_files, target, collection) self.export_collection(transfer_id, total_files, target, collection)
.await?; .await?;
self.repository self.repository
.transition_transfer_status( .complete_receive_with_pending_receipt(PendingDeliveryReceiptInsert {
transfer_id, local_transfer_id: transfer_id,
TransferStatus::Receiving, sender_blob_ticket: &sender_blob_ticket,
TransferStatus::Done, request_id: &delivery_receipt.request_id,
) sender_transfer_id: delivery_receipt.transfer_id,
token: &delivery_receipt.token,
})
.await .await
.map_err(VnidropError::repository)?; .map_err(VnidropError::repository)?;
drop(download_tag); drop(download_tag);
self.emit_transfer(transfer_id, "receive", "lifecycle", "done", json!({})); self.emit_transfer(transfer_id, "receive", "lifecycle", "done", json!({}));
let sender_transfer_id = delivery_receipt.transfer_id; self.delivery_receipt_notify.notify_one();
let client = HandshakeService::client(self.endpoint.clone(), sender_addr);
match client.report_delivery(delivery_receipt).await {
Ok(DeliveryReceiptResponse::Recorded) => self.emit_transfer(
transfer_id,
"receive",
"delivery",
"receipt-recorded",
json!({ "sender_transfer_id": sender_transfer_id }),
),
Ok(DeliveryReceiptResponse::Rejected { reason }) => self.emit_transfer(
transfer_id,
"receive",
"delivery",
"receipt-rejected",
json!({ "reason": reason }),
),
Err(error) => self.emit_transfer(
transfer_id,
"receive",
"delivery",
"receipt-failed",
json!({ "reason": error.to_string() }),
),
}
Ok(()) Ok(())
} }

View File

@@ -1,6 +1,9 @@
use crate::{ use crate::{
api::{CoreEvent, ReceivedLocatorKind}, api::{CoreEvent, ReceivedLocatorKind},
repository::{ReceivedArtifactInsert, ReceiverRequestInsert, Repository, TransferUpsert}, repository::{
PendingDeliveryReceiptInsert, ReceivedArtifactInsert, ReceiverRequestInsert, Repository,
TransferUpsert,
},
transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus}, transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus},
}; };
@@ -63,7 +66,7 @@ async fn received_artifacts_survive_history_deletion() {
async fn persists_transfers_and_events_across_reopen() { async fn persists_transfers_and_events_across_reopen() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap(); let repository = Repository::open(temp.path()).await.unwrap();
assert_eq!(repository.schema_version().await.unwrap(), 5); assert_eq!(repository.schema_version().await.unwrap(), 6);
repository repository
.insert_transfer(transfer( .insert_transfer(transfer(
7, 7,
@@ -112,6 +115,57 @@ async fn persists_transfers_and_events_across_reopen() {
assert_eq!(events[0].id, "event-1"); assert_eq!(events[0].id, "event-1");
} }
#[tokio::test]
async fn receive_completion_persists_delivery_receipt_until_recorded() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
repository
.start_receive(transfer(
93,
TransferDirection::Receive,
TransferStatus::Receiving,
))
.await
.unwrap();
repository
.complete_receive_with_pending_receipt(PendingDeliveryReceiptInsert {
local_transfer_id: 93,
sender_blob_ticket: "blob-ticket",
request_id: "request-93",
sender_transfer_id: 39,
token: "receipt-token",
})
.await
.unwrap();
let transfer = repository
.list_transfers()
.await
.unwrap()
.into_iter()
.find(|transfer| transfer.transfer_id == 93)
.unwrap();
assert_eq!(transfer.status, "done");
drop(repository);
let reopened = Repository::open(temp.path()).await.unwrap();
let pending = reopened.list_pending_delivery_receipts().await.unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].local_transfer_id, 93);
assert_eq!(pending[0].sender_transfer_id, 39);
assert_eq!(pending[0].request_id, "request-93");
assert_eq!(pending[0].token, "receipt-token");
reopened
.delete_pending_delivery_receipt("request-93")
.await
.unwrap();
assert!(reopened
.list_pending_delivery_receipts()
.await
.unwrap()
.is_empty());
}
#[tokio::test] #[tokio::test]
async fn receiver_request_can_only_be_resolved_once() { async fn receiver_request_can_only_be_resolved_once() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
@@ -527,7 +581,7 @@ async fn migrates_schema_v2_identity_without_losing_transfer() {
pool.close().await; pool.close().await;
let repository = Repository::open(temp.path()).await.unwrap(); let repository = Repository::open(temp.path()).await.unwrap();
assert_eq!(repository.schema_version().await.unwrap(), 5); assert_eq!(repository.schema_version().await.unwrap(), 6);
let stored = repository.list_transfers().await.unwrap().remove(0); let stored = repository.list_transfers().await.unwrap().remove(0);
assert_eq!(stored.transfer_id, 7); assert_eq!(stored.transfer_id, 7);
assert_eq!(stored.local_id, "legacy-7-send"); assert_eq!(stored.local_id, "legacy-7-send");

View File

@@ -3,7 +3,7 @@ use std::sync::Arc;
use iroh_blobs::Hash; use iroh_blobs::Hash;
use crate::{ use crate::{
repository::{Repository, TransferUpsert}, repository::{PendingDeliveryReceiptInsert, Repository, TransferUpsert},
transfer_state::{TransferDirection, TransferStatus}, transfer_state::{TransferDirection, TransferStatus},
CoreEvent, CoreEventSink, VnidropCore, VnidropError, CoreEvent, CoreEventSink, VnidropCore, VnidropError,
}; };
@@ -98,6 +98,63 @@ fn startup_recovers_interrupted_transfer_and_persists_event() {
core.shutdown(); core.shutdown();
} }
#[test]
fn startup_processes_persisted_delivery_receipts() {
let temp = tempfile::tempdir().unwrap();
let preparation_runtime = tokio::runtime::Runtime::new().unwrap();
preparation_runtime.block_on(async {
let repository = Repository::open(temp.path()).await.unwrap();
repository
.start_receive(TransferUpsert {
transfer_id: 94,
peer_id: None,
direction: TransferDirection::Receive,
status: TransferStatus::Receiving,
transfer_name: Some("completed receive"),
content_hash: Some("hash"),
ticket: None,
file_count: 1,
total_size: 5,
access_mode: "approval_required",
})
.await
.unwrap();
repository
.complete_receive_with_pending_receipt(PendingDeliveryReceiptInsert {
local_transfer_id: 94,
sender_blob_ticket: "invalid-ticket",
request_id: "request-94",
sender_transfer_id: 49,
token: "receipt-token",
})
.await
.unwrap();
});
drop(preparation_runtime);
let core = VnidropCore::initialize(
temp.path().to_string_lossy().to_string(),
Arc::new(TestSink),
)
.unwrap();
let started = std::time::Instant::now();
loop {
if core.list_events(Some(94)).unwrap().iter().any(|event| {
event.phase == "delivery"
&& event.kind == "receipt-rejected"
&& event.data_json.contains("invalid-sender-ticket")
}) {
break;
}
assert!(
started.elapsed() < std::time::Duration::from_secs(2),
"startup did not process the persisted delivery receipt"
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
core.shutdown();
}
#[test] #[test]
fn startup_fails_persisted_share_when_root_blob_is_missing() { fn startup_fails_persisted_share_when_root_blob_is_missing() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();

View File

@@ -1,6 +1,9 @@
mod support; mod support;
use std::sync::Arc; use std::{
sync::Arc,
time::{Duration, Instant},
};
use support::{ use support::{
receive_with_response, share_path, wait_for_receiver_request, CoreGuard, RecordingSink, receive_with_response, share_path, wait_for_receiver_request, CoreGuard, RecordingSink,
@@ -8,6 +11,24 @@ use support::{
}; };
use vnidrop::{CoreLimits, ShareMetadataInput, ShareSource, SourceKind, TransferAccessMode}; use vnidrop::{CoreLimits, ShareMetadataInput, ShareSource, SourceKind, TransferAccessMode};
fn wait_for_completed_delivery(
sender: &vnidrop::VnidropCore,
transfer_id: u64,
) -> Vec<vnidrop::ReceiverRequest> {
let started = Instant::now();
loop {
let requests = sender.list_receiver_requests(transfer_id).unwrap();
if requests.iter().any(|request| request.status == "completed") {
return requests;
}
assert!(
started.elapsed() < Duration::from_secs(5),
"delivery receipt was not recorded"
);
std::thread::sleep(Duration::from_millis(10));
}
}
#[test] #[test]
fn public_share_receives_without_sender_approval() { fn public_share_receives_without_sender_approval() {
let source_dir = tempfile::tempdir().unwrap(); let source_dir = tempfile::tempdir().unwrap();
@@ -47,10 +68,7 @@ fn public_share_receives_without_sender_approval() {
std::fs::read(output_dir.path().join("public.txt")).unwrap(), std::fs::read(output_dir.path().join("public.txt")).unwrap(),
b"public content" b"public content"
); );
let deliveries = sender let deliveries = wait_for_completed_delivery(&sender.core, share.transfer_id);
.core
.list_receiver_requests(share.transfer_id)
.unwrap();
assert_eq!(deliveries.len(), 1); assert_eq!(deliveries.len(), 1);
assert_eq!(deliveries[0].receiver_name.as_deref(), Some("Receiver")); assert_eq!(deliveries[0].receiver_name.as_deref(), Some("Receiver"));
assert_eq!(deliveries[0].status, "completed"); assert_eq!(deliveries[0].status, "completed");
@@ -104,10 +122,7 @@ fn approval_required_denies_then_allows_receiver() {
std::fs::read(allowed_output.path().join("private.txt")).unwrap(), std::fs::read(allowed_output.path().join("private.txt")).unwrap(),
b"approved content" b"approved content"
); );
let completed = sender let completed = wait_for_completed_delivery(&sender.core, share.transfer_id);
.core
.list_receiver_requests(share.transfer_id)
.unwrap();
assert!(completed assert!(completed
.iter() .iter()
.any(|request| request.status == "completed")); .any(|request| request.status == "completed"));

View File

@@ -131,6 +131,7 @@ kotlin {
} }
jvmMain.dependencies { jvmMain.dependencies {
implementation(libs.filekit.dialogs) implementation(libs.filekit.dialogs)
implementation(libs.jna.platform)
} }
commonMain.dependencies { commonMain.dependencies {
implementation(libs.compose.runtime) implementation(libs.compose.runtime)

View File

@@ -50,7 +50,7 @@ class CoreRepository(
val transferId = model.transferId val transferId = model.transferId
if (transferId != null) { if (transferId != null) {
when (model.phase) { when (model.phase) {
"approval" -> _signals.tryEmit(CoreSignal.ApprovalChanged(transferId)) "approval", "access" -> _signals.tryEmit(CoreSignal.ApprovalChanged(transferId))
"delivery" -> _signals.tryEmit(CoreSignal.ReceiverHistoryChanged(transferId)) "delivery" -> _signals.tryEmit(CoreSignal.ReceiverHistoryChanged(transferId))
} }
if (model.shouldRefreshTransfers()) { if (model.shouldRefreshTransfers()) {

View File

@@ -35,6 +35,8 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vnidrop.app.core.CoreEventModel import com.vnidrop.app.core.CoreEventModel
import com.vnidrop.app.core.ReceiverDeliveryStatus
import com.vnidrop.app.core.ReceiverRequestModel
import com.vnidrop.app.core.Transfer import com.vnidrop.app.core.Transfer
import com.vnidrop.app.core.TransferStatus import com.vnidrop.app.core.TransferStatus
import com.vnidrop.app.ui.components.PillTone import com.vnidrop.app.ui.components.PillTone
@@ -79,6 +81,7 @@ internal fun SendFloatingAction(onClick: () -> Unit, modifier: Modifier = Modifi
internal fun TransferCatalog( internal fun TransferCatalog(
transfers: List<Transfer>, transfers: List<Transfer>,
transferThumbnails: Map<ULong, ByteArray>, transferThumbnails: Map<ULong, ByteArray>,
receiversByTransfer: Map<ULong, List<ReceiverRequestModel>> = emptyMap(),
events: List<CoreEventModel> = emptyList(), events: List<CoreEventModel> = emptyList(),
windowClass: WindowClass, windowClass: WindowClass,
onOpenComposer: () -> Unit, onOpenComposer: () -> Unit,
@@ -107,9 +110,18 @@ internal fun TransferCatalog(
) )
} }
items(transfers, key = Transfer::localId) { transfer -> items(transfers, key = Transfer::localId) { transfer ->
val activeReceiverEndpointIds = receiversByTransfer[transfer.transferId]
.orEmpty()
.filter { it.status == ReceiverDeliveryStatus.Accepted }
.mapTo(mutableSetOf()) { it.remoteEndpointId }
val progress = when (transfer.status) { val progress = when (transfer.status) {
TransferStatus.Importing -> progressForTransfer(events, transfer.transferId) TransferStatus.Importing -> progressForTransfer(events, transfer.transferId)
TransferStatus.Sharing -> activeSendProgress(events, transfer.transferId, transfer.totalSize) TransferStatus.Sharing -> activeSendProgress(
events,
transfer.transferId,
activeReceiverEndpointIds,
transfer.totalSize,
)
else -> null else -> null
} }
TransferListItem( TransferListItem(

View File

@@ -69,6 +69,7 @@ fun SendScreen(
TransferCatalog( TransferCatalog(
transfers = outgoingTransfers, transfers = outgoingTransfers,
transferThumbnails = state.transferThumbnails, transferThumbnails = state.transferThumbnails,
receiversByTransfer = state.receiversByTransfer,
events = coreState.events, events = coreState.events,
windowClass = windowClass, windowClass = windowClass,
onOpenComposer = onOpenComposer, onOpenComposer = onOpenComposer,

View File

@@ -8,6 +8,8 @@ import com.vnidrop.app.core.FileSystemService
import com.vnidrop.app.core.PickedShareFile import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.ShareAccessPolicy import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.ReceiverRequestModel import com.vnidrop.app.core.ReceiverRequestModel
import com.vnidrop.app.core.TransferDirection
import com.vnidrop.app.core.TransferStatus
import com.vnidrop.app.preferences.PreferencesRepository import com.vnidrop.app.preferences.PreferencesRepository
import com.vnidrop.app.ui.feedback.UiMessage import com.vnidrop.app.ui.feedback.UiMessage
import com.vnidrop.app.ui.feedback.UiMessageController import com.vnidrop.app.ui.feedback.UiMessageController
@@ -39,6 +41,7 @@ data class SendState(
val transferThumbnails: Map<ULong, ByteArray> = emptyMap(), val transferThumbnails: Map<ULong, ByteArray> = emptyMap(),
val detailPanel: TransferDetailPanel? = null, val detailPanel: TransferDetailPanel? = null,
val receiverHistory: List<ReceiverRequestModel> = emptyList(), val receiverHistory: List<ReceiverRequestModel> = emptyList(),
val receiversByTransfer: Map<ULong, List<ReceiverRequestModel>> = emptyMap(),
val isLoadingReceivers: Boolean = false, val isLoadingReceivers: Boolean = false,
val isDeleteConfirmationOpen: Boolean = false, val isDeleteConfirmationOpen: Boolean = false,
val isDeleting: Boolean = false, val isDeleting: Boolean = false,
@@ -82,15 +85,24 @@ class SendViewModel(
if (signal.transferId == _state.value.selectedTransferId) { if (signal.transferId == _state.value.selectedTransferId) {
refreshReceivers(signal.transferId) refreshReceivers(signal.transferId)
} }
refreshReceiverStatuses(signal.transferId)
} }
is CoreSignal.ApprovalChanged -> { is CoreSignal.ApprovalChanged -> {
if (signal.transferId == _state.value.selectedTransferId) { if (signal.transferId == _state.value.selectedTransferId) {
refreshReceivers(signal.transferId) refreshReceivers(signal.transferId)
} }
refreshReceiverStatuses(signal.transferId)
} }
} }
} }
} }
viewModelScope.launch {
coreState.map { core ->
core.transfers
.filter { it.direction == TransferDirection.Send && it.status in setOf(TransferStatus.Importing, TransferStatus.Sharing) }
.mapTo(mutableSetOf()) { it.transferId }
}.distinctUntilChanged().collect(::syncSharingReceivers)
}
viewModelScope.launch { viewModelScope.launch {
filePreviewRepository.previews.collect { previews -> filePreviewRepository.previews.collect { previews ->
_state.update { it.copy(transferThumbnails = previews) } _state.update { it.copy(transferThumbnails = previews) }
@@ -316,4 +328,21 @@ class SendViewModel(
) )
} }
} }
private fun syncSharingReceivers(transferIds: Set<ULong>) {
_state.update { current ->
current.copy(receiversByTransfer = current.receiversByTransfer.filterKeys { it in transferIds })
}
transferIds.forEach(::refreshReceiverStatuses)
}
private fun refreshReceiverStatuses(transferId: ULong) {
viewModelScope.launch {
repository.receiverRequests(transferId).onSuccess { requests ->
_state.update { current ->
current.copy(receiversByTransfer = current.receiversByTransfer + (transferId to requests))
}
}
}
}
} }

View File

@@ -34,13 +34,6 @@ internal fun SettingsOverview(
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
SettingsGroup { SettingsGroup {
SettingsRow(
icon = AppIcon.Storage,
title = stringResource(Res.string.storage_title),
selected = state.selectedSection == SettingsSection.Storage,
onClick = { onSectionSelected(SettingsSection.Storage) },
)
SettingsDivider()
SettingsRow( SettingsRow(
icon = AppIcon.User, icon = AppIcon.User,
title = stringResource(Res.string.preferences_title), title = stringResource(Res.string.preferences_title),
@@ -65,6 +58,13 @@ internal fun SettingsOverview(
onClick = { onSectionSelected(SettingsSection.Notifications) }, onClick = { onSectionSelected(SettingsSection.Notifications) },
) )
SettingsDivider() SettingsDivider()
SettingsRow(
icon = AppIcon.Storage,
title = stringResource(Res.string.storage_title),
selected = state.selectedSection == SettingsSection.Storage,
onClick = { onSectionSelected(SettingsSection.Storage) },
)
SettingsDivider()
SettingsRow( SettingsRow(
icon = AppIcon.Info, icon = AppIcon.Info,
title = stringResource(Res.string.about_title), title = stringResource(Res.string.about_title),

View File

@@ -138,7 +138,8 @@ fun progressForReceiver(
detail = null, detail = null,
) )
} }
if (latest.kind == "completed" && transferEvents.none { it.kind == "progress" || it.kind == "started" }) { val progress = aggregateReceiverProgress(transferEvents, totalSizeHint)
if (latest.kind == "completed" && (progress == null || progress >= 0.999f)) {
return TransferProgress( return TransferProgress(
transferId = transferId, transferId = transferId,
phase = "transfer", phase = "transfer",
@@ -149,7 +150,6 @@ fun progressForReceiver(
) )
} }
val progress = aggregateReceiverProgress(transferEvents, totalSizeHint)
return TransferProgress( return TransferProgress(
transferId = transferId, transferId = transferId,
phase = "transfer", phase = "transfer",
@@ -167,36 +167,21 @@ fun progressForReceiver(
fun activeSendProgress( fun activeSendProgress(
events: List<CoreEventModel>, events: List<CoreEventModel>,
transferId: ULong, transferId: ULong,
activeReceiverEndpointIds: Set<String>,
totalSizeHint: ULong? = null, totalSizeHint: ULong? = null,
): TransferProgress? { ): TransferProgress? {
if (activeReceiverEndpointIds.isEmpty()) return null
val endpointIds = events val endpointIds = events
.asSequence() .asSequence()
.filter { it.transferId == transferId && it.direction == "send" && it.phase == "transfer" } .filter { it.transferId == transferId && it.direction == "send" && it.phase == "transfer" }
.mapNotNull { findString(it.dataJson, "endpoint_id") } .mapNotNull { findString(it.dataJson, "endpoint_id") }
.filter { it in activeReceiverEndpointIds }
.distinct() .distinct()
.toList() .toList()
if (endpointIds.isEmpty()) { if (endpointIds.isEmpty()) return null
// Fall back to connection-scoped events without endpoint attribution.
val relevant = events.filter {
it.transferId == transferId &&
it.direction == "send" &&
it.phase == "transfer" &&
it.kind in setOf("started", "progress")
}
if (relevant.isEmpty()) return null
return TransferProgress(
transferId = transferId,
phase = "transfer",
kind = relevant.first().kind,
label = Res.string.progress_sending,
progress = aggregateReceiverProgress(relevant, totalSizeHint),
detail = progressDetail(relevant.first()),
)
}
return endpointIds return endpointIds
.mapNotNull { progressForReceiver(events, transferId, it, totalSizeHint) } .mapNotNull { progressForReceiver(events, transferId, it, totalSizeHint) }
.firstOrNull { it.kind == "progress" || it.kind == "started" } .firstOrNull { it.kind == "progress" || it.kind == "started" }
?: endpointIds.mapNotNull { progressForReceiver(events, transferId, it, totalSizeHint) }.firstOrNull()
} }
fun summarizeProgress(events: List<CoreEventModel>): List<TransferProgress> = fun summarizeProgress(events: List<CoreEventModel>): List<TransferProgress> =

View File

@@ -3,9 +3,12 @@ package com.vnidrop.app.feature
import com.vnidrop.app.DeviceInfo import com.vnidrop.app.DeviceInfo
import com.vnidrop.app.PlatformEnvironment import com.vnidrop.app.PlatformEnvironment
import com.vnidrop.app.core.CoreState import com.vnidrop.app.core.CoreState
import com.vnidrop.app.core.CoreSignal
import com.vnidrop.app.core.PickedShareFile import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.ReceiveFolder import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind import com.vnidrop.app.core.ReceiveFolderKind
import com.vnidrop.app.core.ReceiverDeliveryStatus
import com.vnidrop.app.core.ReceiverRequestModel
import com.vnidrop.app.core.Share import com.vnidrop.app.core.Share
import com.vnidrop.app.core.ShareAccessPolicy import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.Transfer import com.vnidrop.app.core.Transfer
@@ -251,6 +254,38 @@ class ViewModelsTest {
assertEquals(listOf(selected), fileSystem.discardedPickedFiles) assertEquals(listOf(selected), fileSystem.discardedPickedFiles)
} }
@Test
fun sendViewModelTracksReceiverCompletionForCatalogProgress() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val accepted = ReceiverRequestModel(
id = "request-7",
transferId = 7UL,
remoteEndpointId = "peer-a",
transferName = "Photo",
receiverName = "Receiver",
receiverDeviceName = null,
appVersion = "1.0",
status = ReceiverDeliveryStatus.Accepted,
reason = null,
requestedAt = 1L,
respondedAt = 2L,
completedAt = null,
)
val core = FakeCoreGateway().apply {
mutableState.value = CoreState(isInitialized = true, transfers = listOf(sentTransfer(7UL)))
requests[7UL] = listOf(accepted)
}
val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), FakeFilePreviewRepository(), UiMessageController())
advanceUntilIdle()
assertEquals(ReceiverDeliveryStatus.Accepted, viewModel.state.value.receiversByTransfer.getValue(7UL).single().status)
core.requests[7UL] = listOf(accepted.copy(status = ReceiverDeliveryStatus.Completed, completedAt = 3L))
core.mutableSignals.emit(CoreSignal.ReceiverHistoryChanged(7UL))
advanceUntilIdle()
assertEquals(ReceiverDeliveryStatus.Completed, viewModel.state.value.receiversByTransfer.getValue(7UL).single().status)
}
@Test @Test
fun sendComposerClosesAfterSuccessfulAtomicShareCreation() = runTest { fun sendComposerClosesAfterSuccessfulAtomicShareCreation() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler)) Dispatchers.setMain(StandardTestDispatcher(testScheduler))
@@ -722,6 +757,22 @@ class ViewModelsTest {
updatedAt = 1L, updatedAt = 1L,
) )
private fun sentTransfer(id: ULong) = Transfer(
localId = "send-$id",
transferId = id,
direction = TransferDirection.Send,
status = TransferStatus.Sharing,
peerId = null,
transferName = "Sent $id",
contentHash = "hash-$id",
fileCount = 1UL,
totalSize = 42UL,
ticket = "ticket-$id",
accessPolicy = ShareAccessPolicy.RequireApproval,
createdAt = 1L,
updatedAt = 1L,
)
private fun sampleTicketInspection() = com.vnidrop.app.core.TicketInspectionModel( private fun sampleTicketInspection() = com.vnidrop.app.core.TicketInspectionModel(
kind = "vnidrop", kind = "vnidrop",
metadata = com.vnidrop.app.core.TransferMetadataModel(1UL, "Photo", null, "hash", 1UL, 42UL), metadata = com.vnidrop.app.core.TransferMetadataModel(1UL, "Photo", null, "hash", 1UL, 42UL),

View File

@@ -211,9 +211,10 @@ class AppUiModelsTest {
direction = "send", direction = "send",
), ),
) )
val progress = activeSendProgress(events, 7UL, totalSizeHint = 100UL) val progress = activeSendProgress(events, 7UL, setOf("peer-a"), totalSizeHint = 100UL)
assertEquals(0.3f, progress?.progress) assertEquals(0.3f, progress?.progress)
assertEquals(Res.string.progress_sending, progress?.label) assertEquals(Res.string.progress_sending, progress?.label)
assertEquals(null, activeSendProgress(events, 7UL, emptySet(), totalSizeHint = 100UL))
} }
@Test @Test
@@ -270,6 +271,39 @@ class AppUiModelsTest {
assertEquals(1f, progress?.progress) assertEquals(1f, progress?.progress)
} }
@Test
fun progressForReceiverCompletedAfterProgressUsesCompletedLabel() {
val events = listOf(
event(
id = "done",
phase = "transfer",
kind = "completed",
data = """{"connection_id":1,"request_id":1,"endpoint_id":"peer-a"}""",
direction = "send",
),
event(
id = "progress",
phase = "transfer",
kind = "progress",
data = """{"connection_id":1,"request_id":1,"endpoint_id":"peer-a","end_offset":100}""",
direction = "send",
),
event(
id = "started",
phase = "transfer",
kind = "started",
data = """{"connection_id":1,"request_id":1,"endpoint_id":"peer-a","size":100}""",
direction = "send",
),
)
val progress = progressForReceiver(events, 7UL, "peer-a", totalSizeHint = 100UL)
assertEquals("completed", progress?.kind)
assertEquals(Res.string.progress_completed, progress?.label)
assertEquals(1f, progress?.progress)
assertEquals(null, activeSendProgress(events, 7UL, setOf("peer-a"), totalSizeHint = 100UL))
}
private fun storedTransfer(status: TransferStatus): Transfer = private fun storedTransfer(status: TransferStatus): Transfer =
Transfer( Transfer(
localId = "local-1", localId = "local-1",

View File

@@ -32,8 +32,8 @@ actual fun rememberShareFilePicker(
return remember(onFilesPicked, onError, scope) { return remember(onFilesPicked, onError, scope) {
object : ShareFilePicker { object : ShareFilePicker {
override fun pickFiles() { override fun pickFiles() {
if (jvmFilePickerBackend(System.getProperty("os.name")) == JvmFilePickerBackend.XdgPortal) { when (jvmFilePickerBackend(System.getProperty("os.name"))) {
scope.launch { JvmFilePickerBackend.XdgPortal -> scope.launch {
try { try {
val selected = withContext(Dispatchers.IO) { pickShareFilesWithPortal() } val selected = withContext(Dispatchers.IO) { pickShareFilesWithPortal() }
if (selected.isNotEmpty()) onFilesPicked(selected) if (selected.isNotEmpty()) onFilesPicked(selected)
@@ -43,8 +43,20 @@ actual fun rememberShareFilePicker(
onError(error.message ?: error.toString()) onError(error.message ?: error.toString())
} }
} }
} else { JvmFilePickerBackend.WindowsNative -> {
openPicker(onError) { val owner = activeFrame()
scope.launch {
try {
val selected = withContext(Dispatchers.IO) { pickWindowsFiles(owner) }
if (selected.isNotEmpty()) onFilesPicked(selected)
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
onError(error.message ?: error.toString())
}
}
}
JvmFilePickerBackend.AwtSwing -> openPicker(onError) {
val selected = pickShareFiles() val selected = pickShareFiles()
if (selected.isNotEmpty()) onFilesPicked(selected) if (selected.isNotEmpty()) onFilesPicked(selected)
} }
@@ -52,8 +64,8 @@ actual fun rememberShareFilePicker(
} }
override fun pickFolder() { override fun pickFolder() {
if (jvmFilePickerBackend(System.getProperty("os.name")) == JvmFilePickerBackend.XdgPortal) { when (jvmFilePickerBackend(System.getProperty("os.name"))) {
scope.launch { JvmFilePickerBackend.XdgPortal -> scope.launch {
try { try {
val selected = withContext(Dispatchers.IO) { val selected = withContext(Dispatchers.IO) {
pickDirectoryWithPortal("Select folder to share")?.toPickedShareFile(isDirectory = true) pickDirectoryWithPortal("Select folder to share")?.toPickedShareFile(isDirectory = true)
@@ -65,8 +77,22 @@ actual fun rememberShareFilePicker(
onError(error.message ?: error.toString()) onError(error.message ?: error.toString())
} }
} }
} else { JvmFilePickerBackend.WindowsNative -> {
openPicker(onError) { val owner = activeFrame()
scope.launch {
try {
val selected = withContext(Dispatchers.IO) {
pickWindowsFolder("Select folder to share", owner)
} ?: return@launch
onFilesPicked(listOf(selected.toPickedShareFile(isDirectory = true)))
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
onError(error.message ?: error.toString())
}
}
}
JvmFilePickerBackend.AwtSwing -> openPicker(onError) {
val selected = pickDirectory(title = "Select folder to share") ?: return@openPicker val selected = pickDirectory(title = "Select folder to share") ?: return@openPicker
onFilesPicked(listOf(selected.toPickedShareFile(isDirectory = true))) onFilesPicked(listOf(selected.toPickedShareFile(isDirectory = true)))
} }
@@ -85,8 +111,8 @@ actual fun rememberReceiveFolderPicker(
return remember(onFolderPicked, onError, scope) { return remember(onFolderPicked, onError, scope) {
object : ReceiveFolderPicker { object : ReceiveFolderPicker {
override fun pickFolder() { override fun pickFolder() {
if (jvmFilePickerBackend(System.getProperty("os.name")) == JvmFilePickerBackend.XdgPortal) { when (jvmFilePickerBackend(System.getProperty("os.name"))) {
scope.launch { JvmFilePickerBackend.XdgPortal -> scope.launch {
try { try {
val selected = withContext(Dispatchers.IO) { val selected = withContext(Dispatchers.IO) {
pickDirectoryWithPortal("Select receive folder") pickDirectoryWithPortal("Select receive folder")
@@ -98,8 +124,22 @@ actual fun rememberReceiveFolderPicker(
onError(error.message ?: error.toString()) onError(error.message ?: error.toString())
} }
} }
} else { JvmFilePickerBackend.WindowsNative -> {
openPicker(onError) { val owner = activeFrame()
scope.launch {
try {
val selected = withContext(Dispatchers.IO) {
pickWindowsFolder("Select receive folder", owner)
} ?: return@launch
onFolderPicked(selected.toReceiveFolder())
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
onError(error.message ?: error.toString())
}
}
}
JvmFilePickerBackend.AwtSwing -> openPicker(onError) {
val selected = pickDirectory(title = "Select receive folder") ?: return@openPicker val selected = pickDirectory(title = "Select receive folder") ?: return@openPicker
onFolderPicked(selected.toReceiveFolder()) onFolderPicked(selected.toReceiveFolder())
} }
@@ -111,11 +151,16 @@ actual fun rememberReceiveFolderPicker(
internal enum class JvmFilePickerBackend { internal enum class JvmFilePickerBackend {
XdgPortal, XdgPortal,
WindowsNative,
AwtSwing, AwtSwing,
} }
internal fun jvmFilePickerBackend(osName: String?): JvmFilePickerBackend = internal fun jvmFilePickerBackend(osName: String?): JvmFilePickerBackend =
if (osName.orEmpty().startsWith("Linux", ignoreCase = true)) JvmFilePickerBackend.XdgPortal else JvmFilePickerBackend.AwtSwing when {
osName.orEmpty().startsWith("Linux", ignoreCase = true) -> JvmFilePickerBackend.XdgPortal
osName.orEmpty().startsWith("Windows", ignoreCase = true) -> JvmFilePickerBackend.WindowsNative
else -> JvmFilePickerBackend.AwtSwing
}
private fun openPicker( private fun openPicker(
onError: (String) -> Unit, onError: (String) -> Unit,
@@ -167,7 +212,7 @@ private suspend fun pickDirectoryWithPortal(title: String): File? =
dialogSettings = FileKitDialogSettings(title = title, parentWindow = activeFrame()), dialogSettings = FileKitDialogSettings(title = title, parentWindow = activeFrame()),
)?.file )?.file
private fun File.toPickedShareFile(isDirectory: Boolean): PickedShareFile = internal fun File.toPickedShareFile(isDirectory: Boolean): PickedShareFile =
PickedShareFile( PickedShareFile(
value = absolutePath, value = absolutePath,
displayName = name.ifBlank { absolutePath }, displayName = name.ifBlank { absolutePath },

View File

@@ -0,0 +1,157 @@
package com.vnidrop.app.core
import com.sun.jna.Native
import com.sun.jna.Pointer
import com.sun.jna.WString
import com.sun.jna.platform.win32.COM.COMUtils
import com.sun.jna.platform.win32.COM.Unknown
import com.sun.jna.platform.win32.Guid.GUID
import com.sun.jna.platform.win32.Ole32
import com.sun.jna.platform.win32.WinDef.HWND
import com.sun.jna.platform.win32.WinNT.HRESULT
import com.sun.jna.ptr.IntByReference
import com.sun.jna.ptr.PointerByReference
import java.awt.Frame
import java.io.File
internal enum class WindowsFilePickerMode {
Files,
Folder,
}
internal const val FOS_NOCHANGEDIR = 0x00000008
internal const val FOS_PICKFOLDERS = 0x00000020
internal const val FOS_FORCEFILESYSTEM = 0x00000040
internal const val FOS_ALLOWMULTISELECT = 0x00000200
internal const val FOS_PATHMUSTEXIST = 0x00000800
internal const val FOS_FILEMUSTEXIST = 0x00001000
internal fun windowsFilePickerOptions(mode: WindowsFilePickerMode): Int =
FOS_NOCHANGEDIR or FOS_FORCEFILESYSTEM or FOS_PATHMUSTEXIST or when (mode) {
WindowsFilePickerMode.Files -> FOS_ALLOWMULTISELECT or FOS_FILEMUSTEXIST
WindowsFilePickerMode.Folder -> FOS_PICKFOLDERS
}
internal fun pickWindowsFiles(owner: Frame?): List<PickedShareFile> =
showWindowsFilePicker(
title = "Select files to share",
mode = WindowsFilePickerMode.Files,
owner = owner,
).map { it.toPickedShareFile(isDirectory = false) }
internal fun pickWindowsFolder(title: String, owner: Frame?): File? =
showWindowsFilePicker(title, WindowsFilePickerMode.Folder, owner).singleOrNull()
private fun showWindowsFilePicker(
title: String,
mode: WindowsFilePickerMode,
owner: Frame?,
): List<File> {
val ole32 = Ole32.INSTANCE
val initialization = ole32.CoInitializeEx(Pointer.NULL, Ole32.COINIT_APARTMENTTHREADED)
COMUtils.checkRC(initialization)
try {
val dialogReference = PointerByReference()
COMUtils.checkRC(
ole32.CoCreateInstance(
GUID(CLSID_FILE_OPEN_DIALOG),
Pointer.NULL,
CLSCTX_INPROC_SERVER,
GUID(IID_FILE_OPEN_DIALOG),
dialogReference,
),
)
val dialog = FileOpenDialog(dialogReference.value)
try {
val existingOptions = IntByReference()
COMUtils.checkRC(dialog.getOptions(existingOptions))
COMUtils.checkRC(dialog.setOptions(existingOptions.value or windowsFilePickerOptions(mode)))
COMUtils.checkRC(dialog.setTitle(WString(title)))
val ownerHandle = owner
?.takeIf { it.isDisplayable }
?.let { HWND(Native.getWindowPointer(it)) }
val showResult = dialog.show(ownerHandle)
if (showResult.toInt() == HRESULT_CANCELLED) return emptyList()
COMUtils.checkRC(showResult)
return dialog.results(ole32)
} finally {
dialog.Release()
}
} finally {
ole32.CoUninitialize()
}
}
private class FileOpenDialog(pointer: Pointer) : Unknown(pointer) {
fun show(owner: HWND?): HRESULT = invokeHResult(3, owner)
fun setOptions(options: Int): HRESULT = invokeHResult(9, options)
fun getOptions(options: IntByReference): HRESULT = invokeHResult(10, options)
fun setTitle(title: WString): HRESULT = invokeHResult(17, title)
private fun getResults(results: PointerByReference): HRESULT = invokeHResult(27, results)
fun results(ole32: Ole32): List<File> {
val resultsReference = PointerByReference()
COMUtils.checkRC(getResults(resultsReference))
val results = ShellItemArray(resultsReference.value)
try {
val count = IntByReference()
COMUtils.checkRC(results.getCount(count))
return List(count.value) { index -> results.fileAt(index, ole32) }
} finally {
results.Release()
}
}
private fun invokeHResult(index: Int, vararg arguments: Any?): HRESULT =
_invokeNativeObject(index, arrayOf(pointer, *arguments), HRESULT::class.java) as HRESULT
}
private class ShellItemArray(pointer: Pointer) : Unknown(pointer) {
fun getCount(count: IntByReference): HRESULT = invokeHResult(7, count)
private fun getItemAt(index: Int, item: PointerByReference): HRESULT = invokeHResult(8, index, item)
fun fileAt(index: Int, ole32: Ole32): File {
val itemReference = PointerByReference()
COMUtils.checkRC(getItemAt(index, itemReference))
val item = ShellItem(itemReference.value)
try {
return item.file(ole32)
} finally {
item.Release()
}
}
private fun invokeHResult(index: Int, vararg arguments: Any?): HRESULT =
_invokeNativeObject(index, arrayOf(pointer, *arguments), HRESULT::class.java) as HRESULT
}
private class ShellItem(pointer: Pointer) : Unknown(pointer) {
private fun getDisplayName(name: PointerByReference): HRESULT =
invokeHResult(5, SIGDN_FILESYSPATH, name)
fun file(ole32: Ole32): File {
val nameReference = PointerByReference()
COMUtils.checkRC(getDisplayName(nameReference))
val name = nameReference.value
try {
return File(name.getWideString(0))
} finally {
ole32.CoTaskMemFree(name)
}
}
private fun invokeHResult(index: Int, vararg arguments: Any?): HRESULT =
_invokeNativeObject(index, arrayOf(pointer, *arguments), HRESULT::class.java) as HRESULT
}
private const val CLSCTX_INPROC_SERVER = 0x1
private const val HRESULT_CANCELLED = 0x800704C7.toInt()
private const val SIGDN_FILESYSPATH = 0x80058000.toInt()
private const val CLSID_FILE_OPEN_DIALOG = "{DC1C5A9C-E88A-4DDE-A5A1-60F82A20AEF7}"
private const val IID_FILE_OPEN_DIALOG = "{D57C7288-D4AD-4768-BE02-9D969532D960}"

View File

@@ -2,6 +2,8 @@ package com.vnidrop.app.core
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class FilePickerJvmTest { class FilePickerJvmTest {
@Test @Test
@@ -10,9 +12,34 @@ class FilePickerJvmTest {
assertEquals(JvmFilePickerBackend.XdgPortal, jvmFilePickerBackend("linux")) assertEquals(JvmFilePickerBackend.XdgPortal, jvmFilePickerBackend("linux"))
} }
@Test
fun windowsUsesTheModernNativePicker() {
assertEquals(JvmFilePickerBackend.WindowsNative, jvmFilePickerBackend("Windows 11"))
assertEquals(JvmFilePickerBackend.WindowsNative, jvmFilePickerBackend("windows 10"))
}
@Test @Test
fun otherDesktopPlatformsKeepTheirExistingPickers() { fun otherDesktopPlatformsKeepTheirExistingPickers() {
assertEquals(JvmFilePickerBackend.AwtSwing, jvmFilePickerBackend("Windows 11"))
assertEquals(JvmFilePickerBackend.AwtSwing, jvmFilePickerBackend("Mac OS X")) assertEquals(JvmFilePickerBackend.AwtSwing, jvmFilePickerBackend("Mac OS X"))
} }
@Test
fun windowsFilePickerAllowsMultipleFilesystemFiles() {
val options = windowsFilePickerOptions(WindowsFilePickerMode.Files)
assertTrue(options and FOS_FORCEFILESYSTEM != 0)
assertTrue(options and FOS_ALLOWMULTISELECT != 0)
assertTrue(options and FOS_FILEMUSTEXIST != 0)
assertFalse(options and FOS_PICKFOLDERS != 0)
}
@Test
fun windowsFolderPickerSelectsOneFilesystemFolder() {
val options = windowsFilePickerOptions(WindowsFilePickerMode.Folder)
assertTrue(options and FOS_FORCEFILESYSTEM != 0)
assertTrue(options and FOS_PICKFOLDERS != 0)
assertTrue(options and FOS_PATHMUSTEXIST != 0)
assertFalse(options and FOS_ALLOWMULTISELECT != 0)
}
} }