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)
}
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() {
XCTAssertEqual(statusLabelKey(.sharing), "status_available")
XCTAssertEqual(statusLabelKey(.receiving), "status_receiving")
@@ -56,4 +74,8 @@ final class ProgressDerivationTests: XCTestCase {
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 }
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))
default: break
}

View File

@@ -96,14 +96,14 @@ func progressForReceiver(
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(
transferId: transferId, phase: "transfer", kind: "completed",
labelKey: "progress_completed", progress: 1, detail: nil
)
}
let progress = aggregateReceiverProgress(events: transferEvents, totalSizeHint: totalSizeHint)
return TransferProgress(
transferId: transferId, phase: "transfer", kind: latest.kind,
labelKey: "progress_sending", progress: progress, detail: progressDetail(latest)

View File

@@ -127,11 +127,14 @@ struct SendScreen: View {
private func sharingProgress(for transfer: Transfer) -> TransferProgress? {
let active = (model.receiversByTransfer[transfer.transferId] ?? []).filter { $0.status == .accepted }
if active.isEmpty { return nil }
let fractions = active.compactMap {
progressForReceiver(events: model.coreState.events, transferId: transfer.transferId,
remoteEndpointId: $0.remoteEndpointId, totalSizeHint: transfer.totalSize)?.progress
let fractions: [Double] = active.compactMap { receiver -> Double? in
let progress = progressForReceiver(events: model.coreState.events, transferId: transfer.transferId,
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 {
return TransferProgress(transferId: transfer.transferId, phase: "transfer", kind: "progress",
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,
};
const SCHEMA_VERSION: i64 = 5;
const SCHEMA_VERSION: i64 = 6;
#[derive(Debug, Clone)]
pub(crate) struct Repository {
@@ -78,6 +78,23 @@ pub(crate) struct ReceiverRequestInsert<'a> {
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 {
pub(crate) async fn open(app_data_dir: &Path) -> Result<Self> {
let db_path = app_data_dir.join("vnidrop.sqlite3");
@@ -268,6 +285,21 @@ impl Repository {
.execute(&self.pool)
.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}"))
.execute(&self.pool)
.await?;
@@ -506,6 +538,82 @@ impl Repository {
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(
&self,
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
// event even if the process exits soon after Compose disposes the core.
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 {
self.emit_endpoint(
"shutdown",

View File

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

View File

@@ -26,8 +26,8 @@ use crate::{
validated_relative_string, wait_for_writer, write_stream_to_blocking_writer,
AtomicOutputFile,
},
handshake::{DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse, HandshakeService},
repository::{ReceivedArtifactInsert, TransferUpsert},
handshake::{DeliveryReceipt, HandshakeResponse, HandshakeService},
repository::{PendingDeliveryReceiptInsert, ReceivedArtifactInsert, TransferUpsert},
ticket::{parse_transfer_ticket_with_limits, ParsedTransferTicket},
transfer_state::{TransferDirection, TransferStatus},
};
@@ -271,6 +271,7 @@ impl CoreInner {
.map_err(VnidropError::filesystem)?;
}
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!({}));
// 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)
.await?;
self.repository
.transition_transfer_status(
transfer_id,
TransferStatus::Receiving,
TransferStatus::Done,
)
.complete_receive_with_pending_receipt(PendingDeliveryReceiptInsert {
local_transfer_id: transfer_id,
sender_blob_ticket: &sender_blob_ticket,
request_id: &delivery_receipt.request_id,
sender_transfer_id: delivery_receipt.transfer_id,
token: &delivery_receipt.token,
})
.await
.map_err(VnidropError::repository)?;
drop(download_tag);
self.emit_transfer(transfer_id, "receive", "lifecycle", "done", json!({}));
let sender_transfer_id = delivery_receipt.transfer_id;
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() }),
),
}
self.delivery_receipt_notify.notify_one();
Ok(())
}

View File

@@ -1,6 +1,9 @@
use crate::{
api::{CoreEvent, ReceivedLocatorKind},
repository::{ReceivedArtifactInsert, ReceiverRequestInsert, Repository, TransferUpsert},
repository::{
PendingDeliveryReceiptInsert, ReceivedArtifactInsert, ReceiverRequestInsert, Repository,
TransferUpsert,
},
transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus},
};
@@ -63,7 +66,7 @@ async fn received_artifacts_survive_history_deletion() {
async fn persists_transfers_and_events_across_reopen() {
let temp = tempfile::tempdir().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
.insert_transfer(transfer(
7,
@@ -112,6 +115,57 @@ async fn persists_transfers_and_events_across_reopen() {
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]
async fn receiver_request_can_only_be_resolved_once() {
let temp = tempfile::tempdir().unwrap();
@@ -527,7 +581,7 @@ async fn migrates_schema_v2_identity_without_losing_transfer() {
pool.close().await;
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);
assert_eq!(stored.transfer_id, 7);
assert_eq!(stored.local_id, "legacy-7-send");

View File

@@ -3,7 +3,7 @@ use std::sync::Arc;
use iroh_blobs::Hash;
use crate::{
repository::{Repository, TransferUpsert},
repository::{PendingDeliveryReceiptInsert, Repository, TransferUpsert},
transfer_state::{TransferDirection, TransferStatus},
CoreEvent, CoreEventSink, VnidropCore, VnidropError,
};
@@ -98,6 +98,63 @@ fn startup_recovers_interrupted_transfer_and_persists_event() {
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]
fn startup_fails_persisted_share_when_root_blob_is_missing() {
let temp = tempfile::tempdir().unwrap();

View File

@@ -1,6 +1,9 @@
mod support;
use std::sync::Arc;
use std::{
sync::Arc,
time::{Duration, Instant},
};
use support::{
receive_with_response, share_path, wait_for_receiver_request, CoreGuard, RecordingSink,
@@ -8,6 +11,24 @@ use support::{
};
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]
fn public_share_receives_without_sender_approval() {
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(),
b"public content"
);
let deliveries = sender
.core
.list_receiver_requests(share.transfer_id)
.unwrap();
let deliveries = wait_for_completed_delivery(&sender.core, share.transfer_id);
assert_eq!(deliveries.len(), 1);
assert_eq!(deliveries[0].receiver_name.as_deref(), Some("Receiver"));
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(),
b"approved content"
);
let completed = sender
.core
.list_receiver_requests(share.transfer_id)
.unwrap();
let completed = wait_for_completed_delivery(&sender.core, share.transfer_id);
assert!(completed
.iter()
.any(|request| request.status == "completed"));

View File

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

View File

@@ -50,7 +50,7 @@ class CoreRepository(
val transferId = model.transferId
if (transferId != null) {
when (model.phase) {
"approval" -> _signals.tryEmit(CoreSignal.ApprovalChanged(transferId))
"approval", "access" -> _signals.tryEmit(CoreSignal.ApprovalChanged(transferId))
"delivery" -> _signals.tryEmit(CoreSignal.ReceiverHistoryChanged(transferId))
}
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.unit.dp
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.TransferStatus
import com.vnidrop.app.ui.components.PillTone
@@ -79,6 +81,7 @@ internal fun SendFloatingAction(onClick: () -> Unit, modifier: Modifier = Modifi
internal fun TransferCatalog(
transfers: List<Transfer>,
transferThumbnails: Map<ULong, ByteArray>,
receiversByTransfer: Map<ULong, List<ReceiverRequestModel>> = emptyMap(),
events: List<CoreEventModel> = emptyList(),
windowClass: WindowClass,
onOpenComposer: () -> Unit,
@@ -107,9 +110,18 @@ internal fun TransferCatalog(
)
}
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) {
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
}
TransferListItem(

View File

@@ -69,6 +69,7 @@ fun SendScreen(
TransferCatalog(
transfers = outgoingTransfers,
transferThumbnails = state.transferThumbnails,
receiversByTransfer = state.receiversByTransfer,
events = coreState.events,
windowClass = windowClass,
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.ShareAccessPolicy
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.ui.feedback.UiMessage
import com.vnidrop.app.ui.feedback.UiMessageController
@@ -39,6 +41,7 @@ data class SendState(
val transferThumbnails: Map<ULong, ByteArray> = emptyMap(),
val detailPanel: TransferDetailPanel? = null,
val receiverHistory: List<ReceiverRequestModel> = emptyList(),
val receiversByTransfer: Map<ULong, List<ReceiverRequestModel>> = emptyMap(),
val isLoadingReceivers: Boolean = false,
val isDeleteConfirmationOpen: Boolean = false,
val isDeleting: Boolean = false,
@@ -82,15 +85,24 @@ class SendViewModel(
if (signal.transferId == _state.value.selectedTransferId) {
refreshReceivers(signal.transferId)
}
refreshReceiverStatuses(signal.transferId)
}
is CoreSignal.ApprovalChanged -> {
if (signal.transferId == _state.value.selectedTransferId) {
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 {
filePreviewRepository.previews.collect { 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,
)
SettingsGroup {
SettingsRow(
icon = AppIcon.Storage,
title = stringResource(Res.string.storage_title),
selected = state.selectedSection == SettingsSection.Storage,
onClick = { onSectionSelected(SettingsSection.Storage) },
)
SettingsDivider()
SettingsRow(
icon = AppIcon.User,
title = stringResource(Res.string.preferences_title),
@@ -65,6 +58,13 @@ internal fun SettingsOverview(
onClick = { onSectionSelected(SettingsSection.Notifications) },
)
SettingsDivider()
SettingsRow(
icon = AppIcon.Storage,
title = stringResource(Res.string.storage_title),
selected = state.selectedSection == SettingsSection.Storage,
onClick = { onSectionSelected(SettingsSection.Storage) },
)
SettingsDivider()
SettingsRow(
icon = AppIcon.Info,
title = stringResource(Res.string.about_title),

View File

@@ -138,7 +138,8 @@ fun progressForReceiver(
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(
transferId = transferId,
phase = "transfer",
@@ -149,7 +150,6 @@ fun progressForReceiver(
)
}
val progress = aggregateReceiverProgress(transferEvents, totalSizeHint)
return TransferProgress(
transferId = transferId,
phase = "transfer",
@@ -167,36 +167,21 @@ fun progressForReceiver(
fun activeSendProgress(
events: List<CoreEventModel>,
transferId: ULong,
activeReceiverEndpointIds: Set<String>,
totalSizeHint: ULong? = null,
): TransferProgress? {
if (activeReceiverEndpointIds.isEmpty()) return null
val endpointIds = events
.asSequence()
.filter { it.transferId == transferId && it.direction == "send" && it.phase == "transfer" }
.mapNotNull { findString(it.dataJson, "endpoint_id") }
.filter { it in activeReceiverEndpointIds }
.distinct()
.toList()
if (endpointIds.isEmpty()) {
// 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()),
)
}
if (endpointIds.isEmpty()) return null
return endpointIds
.mapNotNull { progressForReceiver(events, transferId, it, totalSizeHint) }
.firstOrNull { it.kind == "progress" || it.kind == "started" }
?: endpointIds.mapNotNull { progressForReceiver(events, transferId, it, totalSizeHint) }.firstOrNull()
}
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.PlatformEnvironment
import com.vnidrop.app.core.CoreState
import com.vnidrop.app.core.CoreSignal
import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.ReceiveFolder
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.ShareAccessPolicy
import com.vnidrop.app.core.Transfer
@@ -251,6 +254,38 @@ class ViewModelsTest {
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
fun sendComposerClosesAfterSuccessfulAtomicShareCreation() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
@@ -722,6 +757,22 @@ class ViewModelsTest {
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(
kind = "vnidrop",
metadata = com.vnidrop.app.core.TransferMetadataModel(1UL, "Photo", null, "hash", 1UL, 42UL),

View File

@@ -211,9 +211,10 @@ class AppUiModelsTest {
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(Res.string.progress_sending, progress?.label)
assertEquals(null, activeSendProgress(events, 7UL, emptySet(), totalSizeHint = 100UL))
}
@Test
@@ -270,6 +271,39 @@ class AppUiModelsTest {
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 =
Transfer(
localId = "local-1",

View File

@@ -32,8 +32,8 @@ actual fun rememberShareFilePicker(
return remember(onFilesPicked, onError, scope) {
object : ShareFilePicker {
override fun pickFiles() {
if (jvmFilePickerBackend(System.getProperty("os.name")) == JvmFilePickerBackend.XdgPortal) {
scope.launch {
when (jvmFilePickerBackend(System.getProperty("os.name"))) {
JvmFilePickerBackend.XdgPortal -> scope.launch {
try {
val selected = withContext(Dispatchers.IO) { pickShareFilesWithPortal() }
if (selected.isNotEmpty()) onFilesPicked(selected)
@@ -43,8 +43,20 @@ actual fun rememberShareFilePicker(
onError(error.message ?: error.toString())
}
}
} else {
openPicker(onError) {
JvmFilePickerBackend.WindowsNative -> {
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()
if (selected.isNotEmpty()) onFilesPicked(selected)
}
@@ -52,8 +64,8 @@ actual fun rememberShareFilePicker(
}
override fun pickFolder() {
if (jvmFilePickerBackend(System.getProperty("os.name")) == JvmFilePickerBackend.XdgPortal) {
scope.launch {
when (jvmFilePickerBackend(System.getProperty("os.name"))) {
JvmFilePickerBackend.XdgPortal -> scope.launch {
try {
val selected = withContext(Dispatchers.IO) {
pickDirectoryWithPortal("Select folder to share")?.toPickedShareFile(isDirectory = true)
@@ -65,8 +77,22 @@ actual fun rememberShareFilePicker(
onError(error.message ?: error.toString())
}
}
} else {
openPicker(onError) {
JvmFilePickerBackend.WindowsNative -> {
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
onFilesPicked(listOf(selected.toPickedShareFile(isDirectory = true)))
}
@@ -85,8 +111,8 @@ actual fun rememberReceiveFolderPicker(
return remember(onFolderPicked, onError, scope) {
object : ReceiveFolderPicker {
override fun pickFolder() {
if (jvmFilePickerBackend(System.getProperty("os.name")) == JvmFilePickerBackend.XdgPortal) {
scope.launch {
when (jvmFilePickerBackend(System.getProperty("os.name"))) {
JvmFilePickerBackend.XdgPortal -> scope.launch {
try {
val selected = withContext(Dispatchers.IO) {
pickDirectoryWithPortal("Select receive folder")
@@ -98,8 +124,22 @@ actual fun rememberReceiveFolderPicker(
onError(error.message ?: error.toString())
}
}
} else {
openPicker(onError) {
JvmFilePickerBackend.WindowsNative -> {
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
onFolderPicked(selected.toReceiveFolder())
}
@@ -111,11 +151,16 @@ actual fun rememberReceiveFolderPicker(
internal enum class JvmFilePickerBackend {
XdgPortal,
WindowsNative,
AwtSwing,
}
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(
onError: (String) -> Unit,
@@ -167,7 +212,7 @@ private suspend fun pickDirectoryWithPortal(title: String): File? =
dialogSettings = FileKitDialogSettings(title = title, parentWindow = activeFrame()),
)?.file
private fun File.toPickedShareFile(isDirectory: Boolean): PickedShareFile =
internal fun File.toPickedShareFile(isDirectory: Boolean): PickedShareFile =
PickedShareFile(
value = 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.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class FilePickerJvmTest {
@Test
@@ -10,9 +12,34 @@ class FilePickerJvmTest {
assertEquals(JvmFilePickerBackend.XdgPortal, jvmFilePickerBackend("linux"))
}
@Test
fun windowsUsesTheModernNativePicker() {
assertEquals(JvmFilePickerBackend.WindowsNative, jvmFilePickerBackend("Windows 11"))
assertEquals(JvmFilePickerBackend.WindowsNative, jvmFilePickerBackend("windows 10"))
}
@Test
fun otherDesktopPlatformsKeepTheirExistingPickers() {
assertEquals(JvmFilePickerBackend.AwtSwing, jvmFilePickerBackend("Windows 11"))
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)
}
}