diff --git a/Cargo.lock b/Cargo.lock index 4948488..158af04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1334,6 +1334,20 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -1343,7 +1357,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 6.0.0", "rand_core 0.10.1", "wasm-bindgen", ] @@ -2187,6 +2201,24 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "irpc-iroh" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2342daed629b312f61e57e452b0750a59da162f261b97f260a6354de61d4fb0e" +dependencies = [ + "getrandom 0.3.4", + "iroh", + "iroh-base", + "irpc", + "n0-error", + "n0-future", + "postcard", + "serde", + "tokio", + "tracing", +] + [[package]] name = "itoa" version = "1.0.18" @@ -3291,6 +3323,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -5025,6 +5063,7 @@ dependencies = [ "iroh", "iroh-blobs", "irpc", + "irpc-iroh", "libc", "n0-future", "num_cpus", @@ -5067,6 +5106,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasite" version = "0.1.0" @@ -5588,6 +5636,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wmi" version = "0.18.4" diff --git a/crates/vnidrop/CORE_FLOW.md b/crates/vnidrop/CORE_FLOW.md new file mode 100644 index 0000000..f63a556 --- /dev/null +++ b/crates/vnidrop/CORE_FLOW.md @@ -0,0 +1,50 @@ +# VniDrop Core Send/Receive Flow + +This crate owns the transfer backend. Platform/UI code should pass file handles +or paths into Rust and react to `CoreEvent` updates; it should not move file +bytes through Kotlin memory. + +## Send + +1. `initialize(app_data_dir, event_sink)` starts the Iroh endpoint, blob + provider, handshake protocol, SQLite repository, and event hub. +2. `share_files(sources, metadata)` validates platform sources, streams each + file into `iroh-blobs`, stores a collection, and returns a VniDrop ticket. +3. New VniDrop shares are `ApprovalRequired` by default. A copied ticket is not + enough to read bytes until the sender approves the receiver endpoint. +4. The sender observes receiver requests through `CoreEvent` entries with + `phase="approval"` and can query them with + `list_receiver_requests(transfer_id)`. +5. `respond_receiver_request(request_id, accepted, reason)` accepts or refuses a + pending request. Accepted requests create a time-limited access session for + the receiver endpoint. + +## Receive + +1. `receive(ticket, output_dir, receiver_name)` parses and validates the ticket. +2. VniDrop tickets first connect to the handshake ALPN + `/vnidrop/handshake/1` and send `RequestTransfer` metadata to the sender. +3. If approved, the receiver connects to the blobs ALPN, downloads the + collection, and streams files to `output_dir`. +4. If refused, expired, unknown, or cancelled, the receive transfer is marked + `failed` or `cancelled` and emits an error/lifecycle event. +5. Legacy raw `BlobTicket` values do not carry VniDrop metadata, so they bypass + the app approval handshake and use the underlying blob ticket directly. + +## Core States And Events + +- Transfer statuses: `sharing`, `receiving`, `done`, `failed`, `cancelled`, + `stopped`. +- Main event phases: `endpoint`, `import`, `ticket`, `handshake`, `approval`, + `access`, `transfer`, `download`, `export`, `lifecycle`, `error`. +- Events are sent to `CoreEventSink` immediately and persisted through the event + hub. `list_events` flushes queued persistence before reading SQLite. +- `shutdown()` is idempotent and flushes events before stopping the router. + +## Platform File Rules + +- Desktop uses normal filesystem paths. +- Android opens SAF/content URIs in Kotlin and passes a borrowed file + descriptor; Rust duplicates the descriptor before streaming. +- iOS starts the security-scoped URL lease in Kotlin and keeps it alive while + Rust streams from the accessible file URL/path. diff --git a/crates/vnidrop/Cargo.toml b/crates/vnidrop/Cargo.toml index 0499954..9925bab 100644 --- a/crates/vnidrop/Cargo.toml +++ b/crates/vnidrop/Cargo.toml @@ -17,6 +17,7 @@ futures-lite = "2.6.1" iroh = "1.0.0" iroh-blobs = "0.103.0" irpc = "0.17.0" +irpc-iroh = "0.17.0" libc = "0.2.186" n0-future = "0.3.1" num_cpus = "1.17.0" diff --git a/crates/vnidrop/src/access_policy.rs b/crates/vnidrop/src/access_policy.rs index 2604b53..6a7cc56 100644 --- a/crates/vnidrop/src/access_policy.rs +++ b/crates/vnidrop/src/access_policy.rs @@ -1,11 +1,9 @@ -use std::{ - collections::{HashMap, HashSet}, - sync::Arc, -}; +use std::{collections::HashMap, sync::Arc}; use tokio::sync::RwLock; use crate::api::TransferAccessMode; +use crate::util::now_ms; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum AccessDecision { @@ -16,7 +14,7 @@ pub(crate) enum AccessDecision { #[derive(Debug, Default)] pub(crate) struct AccessPolicy { modes: RwLock>, - approved_sessions: RwLock>, + approved_sessions: RwLock>, } impl AccessPolicy { @@ -33,14 +31,24 @@ impl AccessPolicy { self.approved_sessions .write() .await - .retain(|(id, _)| *id != transfer_id); + .retain(|(id, _), _| *id != transfer_id); } pub(crate) async fn approve_endpoint(&self, transfer_id: u64, endpoint_id: String) { + self.approve_endpoint_until(transfer_id, endpoint_id, None) + .await; + } + + pub(crate) async fn approve_endpoint_until( + &self, + transfer_id: u64, + endpoint_id: String, + expires_at: Option, + ) { self.approved_sessions .write() .await - .insert((transfer_id, endpoint_id)); + .insert((transfer_id, endpoint_id), ApprovalSession { expires_at }); } pub(crate) async fn decide( @@ -48,9 +56,6 @@ impl AccessPolicy { transfer_id: u64, endpoint_id: Option<&str>, ) -> AccessDecision { - // This is intentionally only the provider-side gate for milestone one. - // A later handshake can add receiver-request/sender-approval events on - // top without weakening the default public sharing behavior. match self .modes .read() @@ -66,19 +71,32 @@ impl AccessPolicy { reason: "missing-endpoint-id", }; }; - if self - .approved_sessions - .read() - .await - .contains(&(transfer_id, endpoint_id.to_string())) - { - AccessDecision::Allow - } else { - AccessDecision::Deny { - reason: "approval-required", + let key = (transfer_id, endpoint_id.to_string()); + let mut sessions = self.approved_sessions.write().await; + match sessions.get(&key) { + Some(session) if session.is_valid(now_ms()) => AccessDecision::Allow, + Some(_) => { + sessions.remove(&key); + AccessDecision::Deny { + reason: "approval-expired", + } } + None => AccessDecision::Deny { + reason: "approval-required", + }, } } } } } + +#[derive(Debug, Clone)] +struct ApprovalSession { + expires_at: Option, +} + +impl ApprovalSession { + fn is_valid(&self, now: i64) -> bool { + self.expires_at.is_none_or(|expires_at| expires_at >= now) + } +} diff --git a/crates/vnidrop/src/api.rs b/crates/vnidrop/src/api.rs index 65de270..9f12cb9 100644 --- a/crates/vnidrop/src/api.rs +++ b/crates/vnidrop/src/api.rs @@ -122,3 +122,18 @@ pub struct TicketInspection { pub blob_ticket: String, pub metadata: Option, } + +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct ReceiverRequest { + pub id: String, + pub transfer_id: u64, + pub remote_endpoint_id: String, + pub transfer_name: String, + pub receiver_name: Option, + pub receiver_device_name: Option, + pub app_version: String, + pub status: String, + pub reason: Option, + pub requested_at: i64, + pub responded_at: Option, +} diff --git a/crates/vnidrop/src/approval.rs b/crates/vnidrop/src/approval.rs new file mode 100644 index 0000000..d63efac --- /dev/null +++ b/crates/vnidrop/src/approval.rs @@ -0,0 +1,221 @@ +use std::{collections::HashMap, sync::Arc, time::Duration}; + +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tokio::sync::{oneshot, Mutex}; +use uuid::Uuid; + +use crate::{ + access_policy::AccessPolicy, + event_hub::EventHub, + handshake::{HandshakeResponse, RequestTransfer}, + repository::{ReceiverRequestInsert, Repository}, + util::now_ms, +}; + +const APPROVAL_TTL_MS: i64 = 10 * 60 * 1000; +const APPROVAL_WAIT_TIMEOUT: Duration = Duration::from_secs(120); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct ApprovalDecision { + pub(crate) request_id: String, + pub(crate) accepted: bool, + pub(crate) reason: Option, +} + +#[derive(Clone)] +pub(crate) struct ApprovalService { + repository: Repository, + event_hub: Arc, + access_policy: Arc, + pending: Arc>>>, +} + +impl ApprovalService { + pub(crate) fn new( + repository: Repository, + event_hub: Arc, + access_policy: Arc, + ) -> Self { + Self { + repository, + event_hub, + access_policy, + pending: Arc::new(Mutex::new(HashMap::new())), + } + } + + pub(crate) async fn respond( + &self, + request_id: String, + accepted: bool, + reason: Option, + ) -> anyhow::Result<()> { + let sender = self.pending.lock().await.remove(&request_id); + let status = if accepted { "accepted" } else { "refused" }; + self.repository + .update_receiver_request_status(&request_id, status, reason.as_deref()) + .await?; + + if let Some(sender) = sender { + let _ = sender.send(ApprovalDecision { + request_id, + accepted, + reason, + }); + } + + Ok(()) + } + + pub(crate) async fn request_transfer( + &self, + remote_endpoint_id: String, + request: RequestTransfer, + ) -> HandshakeResponse { + self.event_hub.emit_transfer( + request.transfer_id, + "send", + "handshake", + "transfer-requested", + json!({ + "remote_endpoint_id": remote_endpoint_id, + "request": request, + }), + ); + + match self + .repository + .send_exists(request.transfer_id, &request.transfer_hash) + .await + { + Ok(true) => { + self.wait_for_sender_decision(remote_endpoint_id, request) + .await + } + Ok(false) => { + self.deny(request.transfer_id, remote_endpoint_id, "unknown-transfer") + .await + } + Err(error) => { + tracing::error!(%error, "failed to validate handshake transfer request"); + self.deny(request.transfer_id, remote_endpoint_id, "repository-error") + .await + } + } + } + + async fn wait_for_sender_decision( + &self, + remote_endpoint_id: String, + request: RequestTransfer, + ) -> HandshakeResponse { + let request_id = Uuid::new_v4().to_string(); + let (tx, rx) = oneshot::channel(); + self.pending.lock().await.insert(request_id.clone(), tx); + + let insert_result = self + .repository + .insert_receiver_request(ReceiverRequestInsert { + id: &request_id, + transfer_id: request.transfer_id, + remote_endpoint_id: &remote_endpoint_id, + transfer_name: &request.transfer_name, + receiver_name: request.receiver_name.as_deref(), + receiver_device_name: request.receiver_device_name.as_deref(), + app_version: &request.app_version, + }) + .await; + + if let Err(error) = insert_result { + self.pending.lock().await.remove(&request_id); + tracing::error!(%error, "failed to persist receiver request"); + return self + .deny(request.transfer_id, remote_endpoint_id, "repository-error") + .await; + } + + self.event_hub.emit_transfer( + request.transfer_id, + "send", + "approval", + "receiver-requested", + json!({ + "request_id": request_id, + "remote_endpoint_id": remote_endpoint_id, + "receiver_name": request.receiver_name, + "receiver_device_name": request.receiver_device_name, + "transfer_name": request.transfer_name, + }), + ); + + match tokio::time::timeout(APPROVAL_WAIT_TIMEOUT, rx).await { + Ok(Ok(decision)) if decision.accepted => { + let token = Uuid::new_v4().to_string(); + let expires_at = now_ms() + APPROVAL_TTL_MS; + self.access_policy + .approve_endpoint_until( + request.transfer_id, + remote_endpoint_id.clone(), + Some(expires_at), + ) + .await; + self.event_hub.emit_transfer( + request.transfer_id, + "send", + "approval", + "receiver-accepted", + json!({ + "request_id": decision.request_id, + "remote_endpoint_id": remote_endpoint_id, + "expires_at": expires_at, + }), + ); + HandshakeResponse::Approved { token, expires_at } + } + Ok(Ok(decision)) => { + self.deny( + request.transfer_id, + remote_endpoint_id, + decision + .reason + .unwrap_or_else(|| "sender-refused".to_string()), + ) + .await + } + Ok(Err(_)) | Err(_) => { + self.pending.lock().await.remove(&request_id); + let _ = self + .repository + .update_receiver_request_status( + &request_id, + "expired", + Some("approval timed out"), + ) + .await; + self.deny(request.transfer_id, remote_endpoint_id, "approval-timeout") + .await + } + } + } + + async fn deny( + &self, + transfer_id: u64, + remote_endpoint_id: String, + reason: impl Into, + ) -> HandshakeResponse { + let reason = reason.into(); + self.event_hub.emit_transfer( + transfer_id, + "send", + "approval", + "receiver-refused", + json!({ + "remote_endpoint_id": remote_endpoint_id, + "reason": reason, + }), + ); + HandshakeResponse::Denied { reason } + } +} diff --git a/crates/vnidrop/src/handshake.rs b/crates/vnidrop/src/handshake.rs new file mode 100644 index 0000000..2a80d95 --- /dev/null +++ b/crates/vnidrop/src/handshake.rs @@ -0,0 +1,120 @@ +use std::fmt; + +use anyhow::Result; +use iroh::{ + endpoint::Connection, + protocol::{AcceptError, ProtocolHandler}, + Endpoint, EndpointAddr, +}; +use irpc::{channel::oneshot, rpc_requests, Client, WithChannels}; +use irpc_iroh::{read_request, IrohLazyRemoteConnection}; +use serde::{Deserialize, Serialize}; + +use crate::api::TransferMetadata; + +#[derive(Clone)] +pub(crate) struct HandshakeService { + approval: crate::approval::ApprovalService, +} + +impl fmt::Debug for HandshakeService { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("HandshakeService") + } +} + +impl HandshakeService { + pub(crate) const ALPN: &'static [u8] = b"/vnidrop/handshake/1"; + + pub(crate) fn new(approval: crate::approval::ApprovalService) -> Self { + Self { approval } + } + + pub(crate) fn client(endpoint: Endpoint, addr: EndpointAddr) -> HandshakeClient { + HandshakeClient { + inner: Client::boxed(IrohLazyRemoteConnection::new( + endpoint, + addr, + Self::ALPN.to_vec(), + )), + } + } + + async fn handle_request( + &self, + remote_endpoint_id: String, + request: RequestTransfer, + ) -> HandshakeResponse { + self.approval + .request_transfer(remote_endpoint_id, request) + .await + } +} + +impl ProtocolHandler for HandshakeService { + async fn accept(&self, connection: Connection) -> Result<(), AcceptError> { + let remote_endpoint_id = connection.remote_id().to_string(); + + while let Some(message) = read_request::(&connection).await? { + match message { + HandshakeMessage::RequestTransfer(message) => { + let WithChannels { inner, tx, .. } = message; + // The receiver-provided name is display data. The trusted + // identity is the endpoint id from the Iroh connection. + let response = self.handle_request(remote_endpoint_id.clone(), inner).await; + let _ = tx.send(response).await; + } + } + } + + connection.closed().await; + Ok(()) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct HandshakeClient { + inner: Client, +} + +impl HandshakeClient { + pub(crate) async fn request_transfer( + &self, + metadata: &TransferMetadata, + receiver_name: Option<&str>, + ) -> Result { + self.inner + .rpc(RequestTransfer { + transfer_id: metadata.transfer_id, + transfer_hash: metadata.content_hash.clone(), + transfer_name: metadata.transfer_name.clone(), + receiver_name: receiver_name.map(ToOwned::to_owned), + receiver_device_name: None, + app_version: env!("CARGO_PKG_VERSION").to_string(), + }) + .await + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct RequestTransfer { + pub(crate) transfer_id: u64, + pub(crate) transfer_hash: String, + pub(crate) transfer_name: String, + pub(crate) receiver_name: Option, + pub(crate) receiver_device_name: Option, + pub(crate) app_version: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) enum HandshakeResponse { + Approved { token: String, expires_at: i64 }, + Denied { reason: String }, +} + +#[rpc_requests(message = HandshakeMessage)] +#[derive(Debug, Serialize, Deserialize)] +enum HandshakeProtocol { + #[rpc(tx=oneshot::Sender)] + RequestTransfer(RequestTransfer), +} diff --git a/crates/vnidrop/src/lib.rs b/crates/vnidrop/src/lib.rs index 802e2fa..1de9c15 100644 --- a/crates/vnidrop/src/lib.rs +++ b/crates/vnidrop/src/lib.rs @@ -1,8 +1,10 @@ mod access_policy; mod api; +mod approval; mod error; mod event_hub; mod filesystem; +mod handshake; mod logging; mod repository; mod runtime; @@ -11,8 +13,9 @@ mod ticket; mod util; pub use api::{ - CoreEvent, CoreEventSink, RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource, - SourceKind, StoredTransfer, TicketInspection, TransferAccessMode, TransferMetadata, + CoreEvent, CoreEventSink, ReceiverRequest, RuntimeStatus, ShareMetadataInput, ShareResult, + ShareSource, SourceKind, StoredTransfer, TicketInspection, TransferAccessMode, + TransferMetadata, }; pub use error::VnidropError; pub use runtime::VnidropCore; diff --git a/crates/vnidrop/src/repository.rs b/crates/vnidrop/src/repository.rs index e45c001..1528b99 100644 --- a/crates/vnidrop/src/repository.rs +++ b/crates/vnidrop/src/repository.rs @@ -6,7 +6,7 @@ use sqlx::{ Row, SqlitePool, }; -use crate::api::{CoreEvent, StoredTransfer}; +use crate::api::{CoreEvent, ReceiverRequest, StoredTransfer}; use crate::util::now_ms; const SCHEMA_VERSION: i64 = 1; @@ -27,6 +27,16 @@ pub(crate) struct TransferUpsert<'a> { pub(crate) total_size: u64, } +pub(crate) struct ReceiverRequestInsert<'a> { + pub(crate) id: &'a str, + pub(crate) transfer_id: u64, + pub(crate) remote_endpoint_id: &'a str, + pub(crate) transfer_name: &'a str, + pub(crate) receiver_name: Option<&'a str>, + pub(crate) receiver_device_name: Option<&'a str>, + pub(crate) app_version: &'a str, +} + impl Repository { pub(crate) async fn open(app_data_dir: &Path) -> Result { let db_path = app_data_dir.join("vnidrop.sqlite3"); @@ -87,6 +97,32 @@ impl Repository { .execute(&self.pool) .await?; + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS receiver_requests ( + id TEXT PRIMARY KEY, + transfer_id INTEGER NOT NULL, + remote_endpoint_id TEXT NOT NULL, + transfer_name TEXT NOT NULL, + receiver_name TEXT, + receiver_device_name TEXT, + app_version TEXT NOT NULL, + status TEXT NOT NULL, + reason TEXT, + requested_at INTEGER NOT NULL, + responded_at INTEGER + ); + "#, + ) + .execute(&self.pool) + .await?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_receiver_requests_transfer_id ON receiver_requests(transfer_id, requested_at DESC);", + ) + .execute(&self.pool) + .await?; + sqlx::query(&format!("PRAGMA user_version = {SCHEMA_VERSION}")) .execute(&self.pool) .await?; @@ -171,6 +207,98 @@ impl Repository { Ok(()) } + pub(crate) async fn insert_receiver_request( + &self, + request: ReceiverRequestInsert<'_>, + ) -> Result<()> { + sqlx::query( + r#" + INSERT INTO receiver_requests ( + id, transfer_id, remote_endpoint_id, transfer_name, + receiver_name, receiver_device_name, app_version, status, + reason, requested_at, responded_at + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'requested', NULL, ?8, NULL) + "#, + ) + .bind(request.id) + .bind(request.transfer_id as i64) + .bind(request.remote_endpoint_id) + .bind(request.transfer_name) + .bind(request.receiver_name) + .bind(request.receiver_device_name) + .bind(request.app_version) + .bind(now_ms()) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub(crate) async fn update_receiver_request_status( + &self, + id: &str, + status: &str, + reason: Option<&str>, + ) -> Result<()> { + let result = sqlx::query( + r#" + UPDATE receiver_requests + SET status = ?1, reason = ?2, responded_at = ?3 + WHERE id = ?4 + AND status = 'requested' + "#, + ) + .bind(status) + .bind(reason) + .bind(now_ms()) + .bind(id) + .execute(&self.pool) + .await?; + if result.rows_affected() == 0 { + anyhow::bail!("receiver request not found or already handled"); + } + Ok(()) + } + + pub(crate) async fn list_receiver_requests( + &self, + transfer_id: u64, + ) -> Result> { + let rows = sqlx::query( + r#" + SELECT id, transfer_id, remote_endpoint_id, transfer_name, + receiver_name, receiver_device_name, app_version, status, + reason, requested_at, responded_at + FROM receiver_requests + WHERE transfer_id = ?1 + ORDER BY requested_at DESC + "#, + ) + .bind(transfer_id as i64) + .fetch_all(&self.pool) + .await?; + Ok(rows.into_iter().map(row_to_receiver_request).collect()) + } + + pub(crate) async fn send_exists(&self, transfer_id: u64, content_hash: &str) -> Result { + let row = sqlx::query( + r#" + SELECT EXISTS( + SELECT 1 FROM transfers + WHERE transfer_id = ?1 + AND content_hash = ?2 + AND direction = 'send' + AND status = 'sharing' + ) + "#, + ) + .bind(transfer_id as i64) + .bind(content_hash) + .fetch_one(&self.pool) + .await?; + Ok(row.get::(0) != 0) + } + pub(crate) async fn list_transfers(&self) -> Result> { let rows = sqlx::query( r#" @@ -243,3 +371,19 @@ fn row_to_event(row: sqlx::sqlite::SqliteRow) -> CoreEvent { data_json: row.get("data_json"), } } + +fn row_to_receiver_request(row: sqlx::sqlite::SqliteRow) -> ReceiverRequest { + ReceiverRequest { + id: row.get("id"), + transfer_id: row.get::("transfer_id") as u64, + remote_endpoint_id: row.get("remote_endpoint_id"), + transfer_name: row.get("transfer_name"), + receiver_name: row.get("receiver_name"), + receiver_device_name: row.get("receiver_device_name"), + app_version: row.get("app_version"), + status: row.get("status"), + reason: row.get("reason"), + requested_at: row.get("requested_at"), + responded_at: row.get("responded_at"), + } +} diff --git a/crates/vnidrop/src/runtime.rs b/crates/vnidrop/src/runtime.rs index 1881006..8d7c5a4 100644 --- a/crates/vnidrop/src/runtime.rs +++ b/crates/vnidrop/src/runtime.rs @@ -32,9 +32,10 @@ use tokio::{ use crate::{ access_policy::{AccessDecision, AccessPolicy}, api::{ - CoreEvent, CoreEventSink, RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource, - StoredTransfer, TicketInspection, TransferAccessMode, TransferMetadata, + CoreEvent, CoreEventSink, ReceiverRequest, RuntimeStatus, ShareMetadataInput, ShareResult, + ShareSource, StoredTransfer, TicketInspection, TransferAccessMode, TransferMetadata, }, + approval::ApprovalService, error::VnidropError, event_hub::EventHub, filesystem::{ @@ -42,6 +43,7 @@ use crate::{ read_stream_from_blocking_reader, safe_output_path, wait_for_writer, write_stream_to_blocking_writer, TransferImport, }, + handshake::{HandshakeResponse, HandshakeService}, logging::init_logging, repository::{Repository, TransferUpsert}, secret::load_or_create_secret, @@ -69,7 +71,8 @@ struct CoreInner { router: Router, store: FsStore, repository: Repository, - event_hub: EventHub, + event_hub: Arc, + approval: ApprovalService, access_policy: Arc, active_transfers: TokioMutex>>, active_shares: TokioMutex>, @@ -165,6 +168,26 @@ impl VnidropCore { .map_err(VnidropError::permission) } + pub fn list_receiver_requests( + &self, + transfer_id: u64, + ) -> Result, VnidropError> { + self.runtime + .block_on(self.inner.repository.list_receiver_requests(transfer_id)) + .map_err(VnidropError::repository) + } + + pub fn respond_receiver_request( + &self, + request_id: String, + accepted: bool, + reason: Option, + ) -> Result<(), VnidropError> { + self.runtime + .block_on(self.inner.approval.respond(request_id, accepted, reason)) + .map_err(VnidropError::permission) + } + pub fn list_transfers(&self) -> Result, VnidropError> { self.runtime .block_on(self.inner.repository.list_transfers()) @@ -215,10 +238,15 @@ impl CoreInner { // uses them for send progress and for the current approval gate. let (events, event_rx) = EventSender::channel(128, EventMask::ALL_READONLY); let blobs = BlobsProtocol::new(&store, Some(events)); + let event_hub = Arc::new(EventHub::start(repository.clone(), event_sink)); + let access_policy = AccessPolicy::new(); + let approval = + ApprovalService::new(repository.clone(), event_hub.clone(), access_policy.clone()); + let handshake = HandshakeService::new(approval.clone()); let router = Router::builder(endpoint.clone()) .accept(iroh_blobs::ALPN, blobs) + .accept(HandshakeService::ALPN, handshake) .spawn(); - let event_hub = EventHub::start(repository.clone(), event_sink); let inner = Arc::new(Self { endpoint, @@ -226,7 +254,8 @@ impl CoreInner { store, repository, event_hub, - access_policy: AccessPolicy::new(), + approval, + access_policy, active_transfers: TokioMutex::new(HashMap::new()), active_shares: TokioMutex::new(HashMap::new()), hash_to_transfer: TokioMutex::new(HashMap::new()), @@ -320,7 +349,7 @@ impl CoreInner { .await .insert(import.root_hash.to_string(), metadata.transfer_id); self.access_policy - .set_mode(metadata.transfer_id, TransferAccessMode::Public) + .set_mode(metadata.transfer_id, TransferAccessMode::ApprovalRequired) .await; self.active_shares .lock() @@ -464,6 +493,15 @@ impl CoreInner { tokio::fs::create_dir_all(&output_dir).await?; self.emit_transfer(transfer_id, "receive", "network", "connecting", json!({})); + if let Some(metadata) = &parsed.metadata { + self.request_transfer_approval( + transfer_id, + parsed.blob_ticket.addr().clone(), + metadata, + receiver_name.as_deref(), + ) + .await?; + } let connection = self .endpoint .connect(parsed.blob_ticket.addr().clone(), iroh_blobs::ALPN) @@ -583,6 +621,49 @@ impl CoreInner { Ok(()) } + async fn request_transfer_approval( + &self, + local_transfer_id: u64, + addr: iroh::EndpointAddr, + metadata: &TransferMetadata, + receiver_name: Option<&str>, + ) -> Result<()> { + self.emit_transfer( + local_transfer_id, + "receive", + "handshake", + "approval-requesting", + json!({ + "sender_transfer_id": metadata.transfer_id, + "metadata": metadata, + }), + ); + + let client = HandshakeService::client(self.endpoint.clone(), addr); + match client + .request_transfer(metadata, receiver_name) + .await + .map_err(|error| anyhow::anyhow!("handshake request failed: {error}"))? + { + HandshakeResponse::Approved { expires_at, .. } => { + self.emit_transfer( + local_transfer_id, + "receive", + "handshake", + "approval-granted", + json!({ + "sender_transfer_id": metadata.transfer_id, + "expires_at": expires_at, + }), + ); + Ok(()) + } + HandshakeResponse::Denied { reason } => { + anyhow::bail!("transfer request was denied by sender: {reason}") + } + } + } + async fn shutdown(&self) { if self.shutdown_started.swap(true, Ordering::SeqCst) { return; diff --git a/crates/vnidrop/src/tests.rs b/crates/vnidrop/src/tests.rs index 12217db..9672cd9 100644 --- a/crates/vnidrop/src/tests.rs +++ b/crates/vnidrop/src/tests.rs @@ -17,7 +17,7 @@ mod tests { collect_import_files, default_collection_name, path_to_string, percent_decode_file_url_path, validated_relative_string, }, - repository::Repository, + repository::{ReceiverRequestInsert, Repository}, runtime::VnidropCore, secret::load_or_create_secret, ticket::{parse_transfer_ticket, VnidropTicket}, @@ -292,6 +292,42 @@ mod tests { assert_eq!(events[0].id, "event-1"); } + #[tokio::test] + async fn repository_persists_receiver_requests() { + let temp = tempfile::tempdir().unwrap(); + let repository = Repository::open(temp.path()).await.unwrap(); + repository + .insert_receiver_request(ReceiverRequestInsert { + id: "request-1", + transfer_id: 77, + remote_endpoint_id: "node-a", + transfer_name: "demo", + receiver_name: Some("receiver"), + receiver_device_name: Some("phone"), + app_version: "0.1.0", + }) + .await + .unwrap(); + repository + .update_receiver_request_status("request-1", "accepted", None) + .await + .unwrap(); + assert!(repository + .update_receiver_request_status("request-1", "refused", Some("late")) + .await + .is_err()); + assert!(repository + .update_receiver_request_status("missing", "accepted", None) + .await + .is_err()); + + let requests = repository.list_receiver_requests(77).await.unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].status, "accepted"); + assert_eq!(requests[0].receiver_name.as_deref(), Some("receiver")); + assert!(requests[0].responded_at.is_some()); + } + #[tokio::test] async fn access_policy_requires_approved_endpoint_when_locked() { let policy = AccessPolicy::new(); @@ -319,6 +355,30 @@ mod tests { ); } + #[tokio::test] + async fn access_policy_rejects_expired_approval_sessions() { + let policy = AccessPolicy::new(); + policy + .set_mode(100, TransferAccessMode::ApprovalRequired) + .await; + policy + .approve_endpoint_until(100, "node-a".to_string(), Some(crate::util::now_ms() - 1)) + .await; + + assert_eq!( + policy.decide(100, Some("node-a")).await, + AccessDecision::Deny { + reason: "approval-expired" + } + ); + assert_eq!( + policy.decide(100, Some("node-a")).await, + AccessDecision::Deny { + reason: "approval-required" + } + ); + } + #[test] fn invalid_receive_ticket_is_typed_and_persisted_as_event() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/vnidrop/tests/local_transfer.rs b/crates/vnidrop/tests/local_transfer.rs index 077a8cc..cc249f3 100644 --- a/crates/vnidrop/tests/local_transfer.rs +++ b/crates/vnidrop/tests/local_transfer.rs @@ -1,7 +1,10 @@ -use std::sync::{Arc, Mutex}; +use std::{ + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; use vnidrop::{ - CoreEvent, CoreEventSink, ShareMetadataInput, ShareSource, SourceKind, TransferAccessMode, + CoreEvent, CoreEventSink, ReceiverRequest, ShareMetadataInput, ShareSource, SourceKind, VnidropCore, }; @@ -22,6 +25,49 @@ impl RecordingSink { } } +fn wait_for_receiver_request(sender: &VnidropCore, transfer_id: u64) -> ReceiverRequest { + let started = Instant::now(); + loop { + let requests = sender.list_receiver_requests(transfer_id).unwrap(); + if let Some(request) = requests + .into_iter() + .find(|request| request.status == "requested") + { + return request; + } + assert!( + started.elapsed() < Duration::from_secs(15), + "timed out waiting for receiver request" + ); + std::thread::sleep(Duration::from_millis(50)); + } +} + +fn receive_with_response( + sender: &VnidropCore, + transfer_id: u64, + receiver: Arc, + ticket: String, + output_dir: String, + receiver_name: Option, + accepted: bool, +) -> Result<(), String> { + let handle = std::thread::spawn(move || { + receiver + .receive(ticket, output_dir, receiver_name) + .map_err(|error| error.to_string()) + }); + let request = wait_for_receiver_request(sender, transfer_id); + sender + .respond_receiver_request( + request.id, + accepted, + (!accepted).then(|| "sender-refused".to_string()), + ) + .unwrap(); + handle.join().unwrap() +} + #[test] fn two_local_cores_transfer_file() { let sender_dir = tempfile::tempdir().unwrap(); @@ -63,13 +109,16 @@ fn two_local_cores_transfer_file() { ) .unwrap(); - receiver - .receive( - share.ticket, - output_dir.path().to_string_lossy().to_string(), - Some("receiver".to_string()), - ) - .unwrap(); + receive_with_response( + &sender, + share.transfer_id, + receiver.clone(), + share.ticket, + output_dir.path().to_string_lossy().to_string(), + Some("receiver".to_string()), + true, + ) + .unwrap(); assert_eq!( std::fs::read(output_dir.path().join("hello.txt")).unwrap(), @@ -121,13 +170,16 @@ fn two_local_cores_transfer_directory() { ) .unwrap(); - receiver - .receive( - share.ticket, - output_dir.path().to_string_lossy().to_string(), - Some("receiver".to_string()), - ) - .unwrap(); + receive_with_response( + &sender, + share.transfer_id, + receiver.clone(), + share.ticket, + output_dir.path().to_string_lossy().to_string(), + Some("receiver".to_string()), + true, + ) + .unwrap(); assert_eq!( std::fs::read(output_dir.path().join("photos").join("cover.txt")).unwrap(), @@ -189,32 +241,31 @@ fn approval_required_denies_then_allows_receiver() { }, ) .unwrap(); - sender - .set_transfer_access_mode(share.transfer_id, TransferAccessMode::ApprovalRequired) - .unwrap(); - - assert!(receiver - .receive( - share.ticket.clone(), - denied_output.path().to_string_lossy().to_string(), - Some("receiver".to_string()), - ) - .is_err()); + assert!(receive_with_response( + &sender, + share.transfer_id, + receiver.clone(), + share.ticket.clone(), + denied_output.path().to_string_lossy().to_string(), + Some("receiver".to_string()), + false, + ) + .is_err()); assert!(sender_sink .events() .iter() - .any(|event| event.phase == "access" && event.kind == "request-denied")); + .any(|event| event.phase == "approval" && event.kind == "receiver-refused")); - sender - .approve_endpoint_for_transfer(share.transfer_id, receiver.status().endpoint_id) - .unwrap(); - receiver - .receive( - share.ticket, - allowed_output.path().to_string_lossy().to_string(), - Some("receiver".to_string()), - ) - .unwrap(); + receive_with_response( + &sender, + share.transfer_id, + receiver.clone(), + share.ticket, + allowed_output.path().to_string_lossy().to_string(), + Some("receiver".to_string()), + true, + ) + .unwrap(); assert_eq!( std::fs::read(allowed_output.path().join("private.txt")).unwrap(), b"approved content" diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index b862bb8..98aadd7 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -1,5 +1,6 @@ @file:OptIn(gobley.gradle.InternalGobleyGradleApi::class) +import gobley.gradle.cargo.dsl.appleMobile import gobley.gradle.rust.targets.RustAndroidTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget @@ -76,6 +77,15 @@ cargo { packageDirectory = layout.projectDirectory.dir("../crates/vnidrop") publishJvmArtifacts = true androidTargetsToBuild.set(setOf(RustAndroidTarget.Arm64)) + builds.appleMobile { + variants { + buildTaskProvider.configure { + if (rustTarget.cinteropName == "ios") { + additionalEnvironment.put("IPHONEOS_DEPLOYMENT_TARGET", "16.0.0") + } + } + } + } } uniffi { diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt index 4db8334..4e4782e 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt @@ -1,228 +1,643 @@ package com.vnidrop.app +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeContentPadding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material3.Button +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vnidrop.app.core.CoreRepository +import com.vnidrop.app.core.CoreUiState import com.vnidrop.app.core.PickedShareFile import com.vnidrop.app.core.rememberShareFilePicker import com.vnidrop.app.core.sharePickedFile +import com.vnidrop.app.ui.components.AppCard +import com.vnidrop.app.ui.components.ErrorBanner +import com.vnidrop.app.ui.components.Field +import com.vnidrop.app.ui.components.MetadataRow +import com.vnidrop.app.ui.components.PillTone +import com.vnidrop.app.ui.components.PrimaryButton +import com.vnidrop.app.ui.components.ProgressRow +import com.vnidrop.app.ui.components.QuietButton +import com.vnidrop.app.ui.components.SecondaryButton +import com.vnidrop.app.ui.components.StatusPill +import com.vnidrop.app.ui.state.AppDestination +import com.vnidrop.app.ui.state.AppUiState +import com.vnidrop.app.ui.state.ReceiveUiState +import com.vnidrop.app.ui.state.SendUiState +import com.vnidrop.app.ui.state.WindowClass +import com.vnidrop.app.ui.state.activeReceiverRequests +import com.vnidrop.app.ui.state.displayNameForStatus +import com.vnidrop.app.ui.state.formatBytes +import com.vnidrop.app.ui.state.friendlyCoreError +import com.vnidrop.app.ui.state.summarizeProgress +import com.vnidrop.app.ui.state.transferSubtitle +import com.vnidrop.app.ui.state.windowClassFor +import com.vnidrop.app.ui.theme.LocalVniDropColors +import com.vnidrop.app.ui.theme.ThemeMode +import com.vnidrop.app.ui.theme.VniDropTheme import kotlinx.coroutines.launch +import uniffi.vnidrop.CoreEvent +import uniffi.vnidrop.ReceiverRequest +import uniffi.vnidrop.ShareResult +import uniffi.vnidrop.StoredTransfer +import uniffi.vnidrop.TicketInspection @Composable @Preview fun App() { val platform = remember { getPlatform() } val repository = remember { CoreRepository() } - val state by repository.state.collectAsState() - val scope = rememberCoroutineScope() - val clipboardManager = LocalClipboardManager.current + val coreState by repository.state.collectAsState() + var appState by remember { mutableStateOf(AppUiState()) } var appDataDir by remember { mutableStateOf(platform.defaultCoreDataDir) } - var sourcePath by remember { mutableStateOf("") } + var sendState by remember { mutableStateOf(SendUiState()) } + var receiveState by remember { mutableStateOf(ReceiveUiState(outputDirectory = platform.defaultReceiveDir)) } var selectedFile by remember { mutableStateOf(null) } - var transferName by remember { mutableStateOf("VniDrop transfer") } - var senderName by remember { mutableStateOf("") } - var ticket by remember { mutableStateOf("") } - var receiveDir by remember { mutableStateOf(platform.defaultReceiveDir) } - var receiverName by remember { mutableStateOf("") } - val shareFilePicker = rememberShareFilePicker( + val scope = rememberCoroutineScope() + val clipboard = LocalClipboardManager.current + val picker = rememberShareFilePicker( onFilePicked = { file -> selectedFile = file - sourcePath = file.value - if (transferName.isBlank() || transferName == "VniDrop transfer") { - transferName = file.displayName - } + sendState = sendState.copy( + selectedSource = file.value, + selectedDisplayName = file.displayName, + transferName = if (sendState.transferName == "VniDrop transfer" || sendState.transferName.isBlank()) { + file.displayName + } else { + sendState.transferName + }, + ) }, onError = { error -> scope.launch { repository.setError(error) } }, ) - MaterialTheme { - Surface( - modifier = Modifier - .safeContentPadding() - .fillMaxSize() - ) { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + LaunchedEffect(coreState.lastShare?.transferId) { + coreState.lastShare?.let { share -> repository.refreshReceiverRequests(share.transferId) } + } + + VniDropTheme(mode = appState.themeMode) { + BoxWithConstraints { + val windowClass = windowClassFor(maxWidth.value) + AppFrame( + appState = appState, + coreState = coreState, + windowClass = windowClass, + onDestinationChange = { appState = appState.copy(destination = it) }, ) { - item { - Text("VniDrop Core", style = MaterialTheme.typography.headlineMedium) - Text(platform.name, style = MaterialTheme.typography.bodySmall) - Text(state.status, style = MaterialTheme.typography.bodyMedium) - } - - item { - CoreCard(title = "Initialize") { - OutlinedTextField( - value = appDataDir, - onValueChange = { appDataDir = it }, - label = { Text("Core data directory") }, - modifier = Modifier.fillMaxWidth(), - ) - Button(onClick = { - scope.launch { repository.initialize(appDataDir) } - }) { - Text("Initialize core") - } - } - } - - item { - CoreCard(title = "Share file") { - OutlinedTextField( - value = sourcePath, - onValueChange = { - sourcePath = it - selectedFile = null - }, - label = { Text("Selected file") }, - modifier = Modifier.fillMaxWidth(), - ) - Button(onClick = { shareFilePicker.pickFile() }) { - Text("Select file") - } - OutlinedTextField( - value = transferName, - onValueChange = { transferName = it }, - label = { Text("Transfer name") }, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = senderName, - onValueChange = { senderName = it }, - label = { Text("Sender name") }, - modifier = Modifier.fillMaxWidth(), - ) - Button( - enabled = state.isInitialized && (selectedFile != null || sourcePath.isNotBlank()), - onClick = { - scope.launch { - val picked = selectedFile - if (picked != null) { - sharePickedFile(repository, picked, transferName, senderName) - } else { - repository.sharePath(sourcePath, transferName, senderName) - } + when (appState.destination) { + AppDestination.Send -> SendScreen( + coreState = coreState, + sendState = sendState, + onSendStateChange = { sendState = it }, + onSelectFile = { picker.pickFile() }, + onCreateShare = { + scope.launch { + sendState = sendState.copy(isSharing = true) + val file = selectedFile + if (file != null) { + sharePickedFile(repository, file, sendState.transferName, sendState.senderName) + } else { + repository.sharePath(sendState.selectedSource, sendState.transferName, sendState.senderName) } - }, - ) { - Text("Create share ticket") - } - state.lastShare?.let { share -> - Text("Ticket") - SelectionContainer { - Text(share.ticket, style = MaterialTheme.typography.bodySmall) + sendState = sendState.copy(isSharing = false) } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = { - clipboardManager.setText(AnnotatedString(share.ticket)) - }) { - Text("Copy ticket") - } - Button(onClick = { ticket = share.ticket }) { - Text("Use locally") - } + }, + onCopyTicket = { ticket -> clipboard.setText(AnnotatedString(ticket)) }, + onUseLocally = { ticket -> + receiveState = receiveState.copy(ticket = ticket) + appState = appState.copy(destination = AppDestination.Receive) + }, + onRefreshRequests = { transferId -> scope.launch { repository.refreshReceiverRequests(transferId) } }, + onRespondRequest = { requestId, accepted -> + scope.launch { repository.respondReceiverRequest(requestId, accepted, reason = if (accepted) null else "sender-refused") } + }, + ) + AppDestination.Receive -> ReceiveScreen( + coreState = coreState, + receiveState = receiveState, + onReceiveStateChange = { receiveState = it }, + onInspect = { scope.launch { repository.inspectTicket(receiveState.ticket) } }, + onReceive = { + scope.launch { + receiveState = receiveState.copy(isReceiving = true) + repository.receive(receiveState.ticket, receiveState.outputDirectory, receiveState.receiverName) + receiveState = receiveState.copy(isReceiving = false) } - } - } + }, + ) + AppDestination.Activity -> ActivityScreen( + coreState = coreState, + onRefresh = { + scope.launch { + repository.refreshTransfers() + repository.refreshEvents() + } + }, + onCancel = { transferId -> scope.launch { repository.cancel(transferId) } }, + ) + AppDestination.Requests -> RequestsScreen( + requests = coreState.receiverRequests, + lastShare = coreState.lastShare, + onRefresh = { transferId -> scope.launch { repository.refreshReceiverRequests(transferId) } }, + onRespond = { requestId, accepted -> + scope.launch { repository.respondReceiverRequest(requestId, accepted, reason = if (accepted) null else "sender-refused") } + }, + ) + AppDestination.Settings -> SettingsScreen( + platformName = platform.name, + appDataDir = appDataDir, + onAppDataDirChange = { appDataDir = it }, + coreState = coreState, + themeMode = appState.themeMode, + onThemeModeChange = { appState = appState.copy(themeMode = it) }, + diagnosticsVisible = appState.diagnosticsVisible, + onDiagnosticsVisibleChange = { appState = appState.copy(diagnosticsVisible = it) }, + onInitialize = { scope.launch { repository.initialize(appDataDir) } }, + ) } - item { - CoreCard(title = "Receive") { - OutlinedTextField( - value = ticket, - onValueChange = { ticket = it }, - label = { Text("Ticket") }, - modifier = Modifier.fillMaxWidth(), - minLines = 3, - ) - OutlinedTextField( - value = receiveDir, - onValueChange = { receiveDir = it }, - label = { Text("Output directory") }, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = receiverName, - onValueChange = { receiverName = it }, - label = { Text("Receiver name") }, - modifier = Modifier.fillMaxWidth(), - ) - Button( - enabled = state.isInitialized && ticket.isNotBlank(), - onClick = { scope.launch { repository.inspectTicket(ticket) } }, - ) { - Text("Inspect ticket") - } - Button( - enabled = state.isInitialized && ticket.isNotBlank() && receiveDir.isNotBlank(), - onClick = { - scope.launch { repository.receive(ticket, receiveDir, receiverName) } - }, - ) { - Text("Receive") - } - state.lastInspection?.let { inspection -> - Text("Kind: ${inspection.kind}") - Text("Blob ticket: ${inspection.blobTicket.take(96)}") - inspection.metadata?.let { metadata -> - Text("${metadata.transferName} | ${metadata.fileCount} files | ${metadata.totalSize} bytes") - } - } - } + if (appState.diagnosticsVisible) { + DiagnosticsPanel(events = coreState.events) } + } + } + } +} - state.error?.let { error -> - item { - Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) { - Text( - text = error, - modifier = Modifier.padding(12.dp), - color = MaterialTheme.colorScheme.onErrorContainer, - ) - } - } +@Composable +private fun AppFrame( + appState: AppUiState, + coreState: CoreUiState, + windowClass: WindowClass, + onDestinationChange: (AppDestination) -> Unit, + content: @Composable () -> Unit, +) { + val colors = LocalVniDropColors.current + Surface( + modifier = Modifier + .fillMaxSize() + .background(colors.canvas) + .safeContentPadding(), + color = colors.canvas, + ) { + if (windowClass == WindowClass.Compact) { + Column(modifier = Modifier.fillMaxSize()) { + TopBar(coreState = coreState) + Box(modifier = Modifier.weight(1f)) { + ScreenContent(content = content) } + BottomNav(selected = appState.destination, onDestinationChange = onDestinationChange) + } + } else { + Row(modifier = Modifier.fillMaxSize()) { + SideNav( + selected = appState.destination, + coreState = coreState, + onDestinationChange = onDestinationChange, + ) + Box(modifier = Modifier.weight(1f)) { + ScreenContent(content = content) + } + } + } + } +} - item { - Text("Events", style = MaterialTheme.typography.titleMedium) +@Composable +private fun ScreenContent(content: @Composable () -> Unit) { + val colors = LocalVniDropColors.current + LazyColumn( + modifier = Modifier + .fillMaxSize() + .background(colors.canvas) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + item { + content() + } + } +} + +@Composable +private fun TopBar(coreState: CoreUiState) { + val colors = LocalVniDropColors.current + Row( + modifier = Modifier + .fillMaxWidth() + .background(colors.sidebar) + .border(BorderStroke(1.dp, colors.border)) + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text("VniDrop", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + NodeStatus(coreState) + } +} + +@Composable +private fun SideNav( + selected: AppDestination, + coreState: CoreUiState, + onDestinationChange: (AppDestination) -> Unit, +) { + val colors = LocalVniDropColors.current + Column( + modifier = Modifier + .width(220.dp) + .fillMaxHeight() + .background(colors.sidebar) + .border(BorderStroke(1.dp, colors.border)) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text("VniDrop", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + Text("Private file transfer", color = colors.textMuted, style = MaterialTheme.typography.bodySmall) + Spacer(Modifier.height(12.dp)) + AppDestination.entries.forEach { destination -> + NavItem( + destination = destination, + selected = destination == selected, + onClick = { onDestinationChange(destination) }, + ) + } + Spacer(Modifier.weight(1f)) + NodeStatus(coreState) + } +} + +@Composable +private fun BottomNav( + selected: AppDestination, + onDestinationChange: (AppDestination) -> Unit, +) { + val colors = LocalVniDropColors.current + Row( + modifier = Modifier + .fillMaxWidth() + .background(colors.sidebar) + .border(BorderStroke(1.dp, colors.border)) + .padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + AppDestination.entries.forEach { destination -> + NavItem( + destination = destination, + selected = destination == selected, + onClick = { onDestinationChange(destination) }, + modifier = Modifier.weight(1f), + compact = true, + ) + } + } +} + +@Composable +private fun NavItem( + destination: AppDestination, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + compact: Boolean = false, +) { + val colors = LocalVniDropColors.current + val background = if (selected) colors.surfaceMuted else colors.sidebar + val border = if (selected) colors.brand.copy(alpha = 0.55f) else colors.border.copy(alpha = 0f) + Box( + modifier = modifier + .clip(RoundedCornerShape(8.dp)) + .background(background) + .border(1.dp, border, RoundedCornerShape(8.dp)) + .selectable(selected = selected, onClick = onClick) + .padding(horizontal = if (compact) 6.dp else 12.dp, vertical = 10.dp), + contentAlignment = Alignment.Center, + ) { + Text( + destination.label, + style = if (compact) MaterialTheme.typography.labelMedium else MaterialTheme.typography.bodyMedium, + color = if (selected) colors.textPrimary else colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun NodeStatus(coreState: CoreUiState) { + StatusPill( + label = if (coreState.isInitialized) "Online" else "Offline", + tone = if (coreState.isInitialized) PillTone.Success else PillTone.Neutral, + ) +} + +@Composable +private fun SendScreen( + coreState: CoreUiState, + sendState: SendUiState, + onSendStateChange: (SendUiState) -> Unit, + onSelectFile: () -> Unit, + onCreateShare: () -> Unit, + onCopyTicket: (String) -> Unit, + onUseLocally: (String) -> Unit, + onRefreshRequests: (ULong) -> Unit, + onRespondRequest: (String, Boolean) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { + ScreenHeader("Send", "Create a VniDrop ticket and approve receivers when required.") + ErrorSection(coreState) + AppCard(title = "Source") { + if (sendState.selectedSource.isBlank()) { + EmptyText("Select a file to start a share. The app keeps bytes in Rust and platform file handles.") + } else { + MetadataRow("Name", sendState.selectedDisplayName.ifBlank { sendState.selectedSource.substringAfterLast('/') }) + MetadataRow("Source", sendState.selectedSource) + } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + PrimaryButton("Select file", onClick = onSelectFile) + SecondaryButton( + text = "Clear", + onClick = { onSendStateChange(sendState.copy(selectedSource = "", selectedDisplayName = "")) }, + enabled = sendState.selectedSource.isNotBlank(), + ) + } + } + AppCard(title = "Transfer details") { + Field( + value = sendState.transferName, + onValueChange = { onSendStateChange(sendState.copy(transferName = it)) }, + label = "Transfer name", + ) + Field( + value = sendState.senderName, + onValueChange = { onSendStateChange(sendState.copy(senderName = it)) }, + label = "Sender name", + ) + PrimaryButton( + text = if (sendState.isSharing) "Creating ticket..." else "Create share ticket", + onClick = onCreateShare, + enabled = coreState.isInitialized && sendState.selectedSource.isNotBlank() && !sendState.isSharing, + ) + } + coreState.lastShare?.let { share -> + ShareResultCard( + share = share, + requests = coreState.receiverRequests, + onCopyTicket = onCopyTicket, + onUseLocally = onUseLocally, + onRefreshRequests = onRefreshRequests, + onRespondRequest = onRespondRequest, + ) + } + ProgressSection(coreState) + } +} + +@Composable +private fun ShareResultCard( + share: ShareResult, + requests: List, + onCopyTicket: (String) -> Unit, + onUseLocally: (String) -> Unit, + onRefreshRequests: (ULong) -> Unit, + onRespondRequest: (String, Boolean) -> Unit, +) { + AppCard(title = "Share ticket", trailing = { + StatusPill("${share.fileCount} file${if (share.fileCount == 1UL) "" else "s"}", tone = PillTone.Brand) + }) { + MetadataRow("Transfer", share.transferName) + MetadataRow("Size", formatBytes(share.totalSize)) + SelectionContainer { + Text( + text = share.ticket, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(LocalVniDropColors.current.surfaceMuted) + .padding(12.dp), + style = MaterialTheme.typography.bodySmall, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + PrimaryButton("Copy", onClick = { onCopyTicket(share.ticket) }) + SecondaryButton("Use locally", onClick = { onUseLocally(share.ticket) }) + SecondaryButton("Refresh", onClick = { onRefreshRequests(share.transferId) }) + } + if (requests.isNotEmpty()) { + HorizontalDivider(color = LocalVniDropColors.current.border) + ReceiverRequestList(requests = requests, onRespondRequest = onRespondRequest) + } + } +} + +@Composable +private fun ReceiveScreen( + coreState: CoreUiState, + receiveState: ReceiveUiState, + onReceiveStateChange: (ReceiveUiState) -> Unit, + onInspect: () -> Unit, + onReceive: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { + ScreenHeader("Receive", "Inspect a ticket, request access, and stream files into the output directory.") + ErrorSection(coreState) + AppCard(title = "Ticket") { + Field( + value = receiveState.ticket, + onValueChange = { onReceiveStateChange(receiveState.copy(ticket = it)) }, + label = "Ticket", + minLines = 4, + ) + Field( + value = receiveState.outputDirectory, + onValueChange = { onReceiveStateChange(receiveState.copy(outputDirectory = it)) }, + label = "Output directory", + ) + Field( + value = receiveState.receiverName, + onValueChange = { onReceiveStateChange(receiveState.copy(receiverName = it)) }, + label = "Receiver name", + ) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + SecondaryButton( + text = "Inspect ticket", + onClick = onInspect, + enabled = coreState.isInitialized && receiveState.ticket.isNotBlank(), + ) + PrimaryButton( + text = if (receiveState.isReceiving) "Receiving..." else "Receive", + onClick = onReceive, + enabled = coreState.isInitialized && + receiveState.ticket.isNotBlank() && + receiveState.outputDirectory.isNotBlank() && + !receiveState.isReceiving, + ) + } + } + coreState.lastInspection?.let { TicketInspectionCard(it) } + ProgressSection(coreState) + } +} + +@Composable +private fun TicketInspectionCard(inspection: TicketInspection) { + AppCard(title = "Ticket details") { + MetadataRow("Kind", inspection.kind) + inspection.metadata?.let { metadata -> + MetadataRow("Transfer", metadata.transferName) + MetadataRow("Sender", metadata.senderName ?: "Unknown") + MetadataRow("Files", metadata.fileCount.toString()) + MetadataRow("Size", formatBytes(metadata.totalSize)) + MetadataRow("Hash", metadata.contentHash) + } ?: EmptyText("This ticket does not include VniDrop metadata.") + } +} + +@Composable +private fun ActivityScreen( + coreState: CoreUiState, + onRefresh: () -> Unit, + onCancel: (ULong) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { + ScreenHeader("Activity", "Follow current and recent transfers from the Rust core.") + ErrorSection(coreState) + AppCard(title = "Transfers", trailing = { SecondaryButton("Refresh", onClick = onRefresh) }) { + if (coreState.transfers.isEmpty()) { + EmptyText("No transfers yet.") + } else { + coreState.transfers.forEach { transfer -> + TransferRow(transfer = transfer, onCancel = onCancel) } - items(state.events, key = { it.id }) { event -> - Card { - Text( - text = "${event.scope}/${event.direction ?: "-"} ${event.phase}:${event.kind}", - modifier = Modifier.padding(start = 12.dp, top = 10.dp, end = 12.dp), - style = MaterialTheme.typography.bodyMedium, - ) - Text( - text = event.dataJson, - modifier = Modifier.padding(start = 12.dp, bottom = 10.dp, end = 12.dp), - style = MaterialTheme.typography.bodySmall, - ) + } + } + ProgressSection(coreState) + } +} + +@Composable +private fun TransferRow(transfer: StoredTransfer, onCancel: (ULong) -> Unit) { + val status = displayNameForStatus(transfer.status) + val tone = when (transfer.status.lowercase()) { + "done" -> PillTone.Success + "failed" -> PillTone.Destructive + "cancelled", "stopped" -> PillTone.Warning + "sharing", "receiving" -> PillTone.Brand + else -> PillTone.Neutral + } + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(LocalVniDropColors.current.surfaceRaised) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.weight(1f)) { + Text(transfer.transferName ?: "Transfer ${transfer.transferId}", fontWeight = FontWeight.SemiBold) + Text(transferSubtitle(transfer), color = LocalVniDropColors.current.textMuted, style = MaterialTheme.typography.bodySmall) + } + StatusPill(status, tone = tone) + } + if (transfer.status == "sharing" || transfer.status == "receiving") { + QuietButton("Cancel", onClick = { onCancel(transfer.transferId) }) + } + } +} + +@Composable +private fun RequestsScreen( + requests: List, + lastShare: ShareResult?, + onRefresh: (ULong) -> Unit, + onRespond: (String, Boolean) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { + ScreenHeader("Requests", "Approve or refuse receivers for the current share.") + AppCard(title = "Receiver requests", trailing = { + lastShare?.let { SecondaryButton("Refresh", onClick = { onRefresh(it.transferId) }) } + }) { + if (lastShare == null) { + EmptyText("Create a share ticket first.") + } else if (requests.isEmpty()) { + EmptyText("No receiver requests yet.") + } else { + ReceiverRequestList(requests = requests, onRespondRequest = onRespond) + } + } + } +} + +@Composable +private fun ReceiverRequestList( + requests: List, + onRespondRequest: (String, Boolean) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + requests.forEach { request -> + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(LocalVniDropColors.current.surfaceRaised) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.weight(1f)) { + Text(request.receiverName ?: "Receiver", fontWeight = FontWeight.SemiBold) + Text(request.remoteEndpointId.take(28), color = LocalVniDropColors.current.textMuted, style = MaterialTheme.typography.bodySmall) + } + StatusPill(displayNameForStatus(request.status), tone = if (request.status == "requested") PillTone.Warning else PillTone.Neutral) + } + request.reason?.let { Text(it, color = LocalVniDropColors.current.textMuted, style = MaterialTheme.typography.bodySmall) } + if (request.status == "requested") { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + SecondaryButton("Refuse", onClick = { onRespondRequest(request.id, false) }) + PrimaryButton("Approve", onClick = { onRespondRequest(request.id, true) }) } } } @@ -231,18 +646,113 @@ fun App() { } @Composable -private fun CoreCard( - title: String, - content: @Composable () -> Unit, +private fun SettingsScreen( + platformName: String, + appDataDir: String, + onAppDataDirChange: (String) -> Unit, + coreState: CoreUiState, + themeMode: ThemeMode, + onThemeModeChange: (ThemeMode) -> Unit, + diagnosticsVisible: Boolean, + onDiagnosticsVisibleChange: (Boolean) -> Unit, + onInitialize: () -> Unit, ) { - Card(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - Text(title, style = MaterialTheme.typography.titleMedium) - HorizontalDivider() - content() + Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { + ScreenHeader("Settings", "Configure the local node and app appearance.") + ErrorSection(coreState) + AppCard(title = "Node") { + MetadataRow("Platform", platformName) + MetadataRow("Status", coreState.status) + Field(value = appDataDir, onValueChange = onAppDataDirChange, label = "Core data directory") + PrimaryButton("Initialize core", onClick = onInitialize) + } + AppCard(title = "Appearance") { + ThemeMode.entries.forEach { mode -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .selectable(selected = themeMode == mode, onClick = { onThemeModeChange(mode) }) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = themeMode == mode, onClick = { onThemeModeChange(mode) }) + Text(mode.name) + } + } + } + AppCard(title = "Diagnostics") { + SecondaryButton( + text = if (diagnosticsVisible) "Hide event log" else "Show event log", + onClick = { onDiagnosticsVisibleChange(!diagnosticsVisible) }, + ) + EmptyText("Diagnostics are intentionally separate from the primary flow so transfer state stays readable.") } } } + +@Composable +private fun DiagnosticsPanel(events: List) { + AppCard(title = "Event log") { + if (events.isEmpty()) { + EmptyText("No events have been emitted yet.") + } else { + Column( + modifier = Modifier + .fillMaxWidth() + .height(280.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + events.forEach { event -> + EventRow(event) + } + } + } + } +} + +@Composable +private fun EventRow(event: CoreEvent) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(LocalVniDropColors.current.surfaceRaised) + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text("${event.scope}/${event.direction ?: "-"} ${event.phase}:${event.kind}", style = MaterialTheme.typography.bodySmall) + Text(event.dataJson, color = LocalVniDropColors.current.textMuted, style = MaterialTheme.typography.bodySmall) + } +} + +@Composable +private fun ProgressSection(coreState: CoreUiState) { + val progress = summarizeProgress(coreState.events) + if (progress.isNotEmpty()) { + AppCard(title = "Progress") { + progress.forEach { item -> + ProgressRow(label = item.label, progress = item.progress) + } + } + } +} + +@Composable +private fun ScreenHeader(title: String, subtitle: String) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + Text(subtitle, color = LocalVniDropColors.current.textMuted, style = MaterialTheme.typography.bodyMedium) + } +} + +@Composable +private fun ErrorSection(coreState: CoreUiState) { + friendlyCoreError(coreState.error)?.let { ErrorBanner(it) } +} + +@Composable +private fun EmptyText(text: String) { + Text(text, color = LocalVniDropColors.current.textMuted, style = MaterialTheme.typography.bodyMedium) +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt index a538842..acb344f 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt @@ -10,10 +10,12 @@ import kotlinx.coroutines.withContext import kotlin.random.Random import uniffi.vnidrop.CoreEvent import uniffi.vnidrop.CoreEventSink +import uniffi.vnidrop.ReceiverRequest import uniffi.vnidrop.ShareMetadataInput import uniffi.vnidrop.ShareResult import uniffi.vnidrop.ShareSource import uniffi.vnidrop.SourceKind +import uniffi.vnidrop.StoredTransfer import uniffi.vnidrop.TicketInspection import uniffi.vnidrop.VnidropCore @@ -21,8 +23,10 @@ data class CoreUiState( val isInitialized: Boolean = false, val status: String = "Not initialized", val events: List = emptyList(), + val transfers: List = emptyList(), val lastShare: ShareResult? = null, val lastInspection: TicketInspection? = null, + val receiverRequests: List = emptyList(), val error: String? = null, ) @@ -46,6 +50,8 @@ class CoreRepository( core?.shutdown() core = VnidropCore.initialize(appDataDir, sink) refreshStatus() + loadTransfers() + loadEvents() _state.update { it.copy(isInitialized = true, error = null) } } @@ -117,6 +123,7 @@ class CoreRepository( suspend fun receive(ticket: String, outputDir: String, receiverName: String) = runCore { requireCore().receive(ticket, outputDir, receiverName.ifBlank { null }) refreshStatus() + loadTransfers() } suspend fun receiveIntoSecurityScopedDirectory( @@ -128,11 +135,34 @@ class CoreRepository( requireCore().receive(ticket, outputDirectoryUrl, receiverName.ifBlank { null }) } refreshStatus() + loadTransfers() } suspend fun cancel(transferId: ULong) = runCore { requireCore().cancelTransfer(transferId) refreshStatus() + loadTransfers() + } + + suspend fun refreshReceiverRequests(transferId: ULong) = runCore { + val requests = requireCore().listReceiverRequests(transferId) + _state.update { it.copy(receiverRequests = requests, error = null) } + } + + suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String? = null) = runCore { + requireCore().respondReceiverRequest(requestId, accepted, reason) + state.value.lastShare?.let { share -> + val requests = requireCore().listReceiverRequests(share.transferId) + _state.update { it.copy(receiverRequests = requests, error = null) } + } + } + + suspend fun refreshTransfers() = runCore { + loadTransfers() + } + + suspend fun refreshEvents() = runCore { + loadEvents() } suspend fun setError(message: String) { @@ -166,9 +196,10 @@ class CoreRepository( senderName = senderName.ifBlank { null }, ), ) - _state.update { it.copy(lastShare = result, error = null) } + _state.update { it.copy(lastShare = result, receiverRequests = emptyList(), error = null) } } refreshStatus() + loadTransfers() } private suspend fun withPlatformPathAccess( @@ -196,6 +227,16 @@ class CoreRepository( } } + private fun loadTransfers() { + val transfers = core?.listTransfers().orEmpty() + _state.update { it.copy(transfers = transfers, error = null) } + } + + private fun loadEvents() { + val events = core?.listEvents(null).orEmpty() + _state.update { it.copy(events = events.take(200), error = null) } + } + private fun nextTransferId(): ULong = Random.nextLong(1, Long.MAX_VALUE).toULong() } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/VniDropComponents.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/VniDropComponents.kt new file mode 100644 index 0000000..bf2a8f6 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/VniDropComponents.kt @@ -0,0 +1,232 @@ +package com.vnidrop.app.ui.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vnidrop.app.ui.theme.LocalVniDropColors + +@Composable +fun AppCard( + title: String, + modifier: Modifier = Modifier, + trailing: @Composable (() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit, +) { + val colors = LocalVniDropColors.current + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors(containerColor = colors.surface), + border = BorderStroke(1.dp, colors.border), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + trailing?.invoke() + } + HorizontalDivider(color = colors.border) + content() + } + } +} + +@Composable +fun Field( + value: String, + onValueChange: (String) -> Unit, + label: String, + modifier: Modifier = Modifier, + minLines: Int = 1, + enabled: Boolean = true, +) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(label) }, + modifier = modifier.fillMaxWidth(), + minLines = minLines, + enabled = enabled, + shape = RoundedCornerShape(8.dp), + ) +} + +@Composable +fun PrimaryButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + val colors = LocalVniDropColors.current + Button( + onClick = onClick, + enabled = enabled, + modifier = modifier.heightIn(min = 44.dp), + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.buttonColors(containerColor = colors.brand, contentColor = Color.White), + ) { + Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + +@Composable +fun SecondaryButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + OutlinedButton( + onClick = onClick, + enabled = enabled, + modifier = modifier.heightIn(min = 44.dp), + shape = RoundedCornerShape(8.dp), + ) { + Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + +@Composable +fun QuietButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + TextButton(onClick = onClick, enabled = enabled, modifier = modifier.heightIn(min = 40.dp)) { + Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + +@Composable +fun StatusPill( + label: String, + modifier: Modifier = Modifier, + tone: PillTone = PillTone.Neutral, +) { + val colors = LocalVniDropColors.current + val color = when (tone) { + PillTone.Neutral -> colors.textMuted + PillTone.Success -> colors.success + PillTone.Warning -> colors.warning + PillTone.Destructive -> colors.destructive + PillTone.Brand -> colors.brand + } + Row( + modifier = modifier + .clip(RoundedCornerShape(999.dp)) + .background(color.copy(alpha = 0.12f)) + .border(1.dp, color.copy(alpha = 0.32f), RoundedCornerShape(999.dp)) + .padding(horizontal = 10.dp, vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box( + modifier = Modifier + .size(7.dp) + .clip(CircleShape) + .background(color), + ) + Text(label, color = color, style = MaterialTheme.typography.labelMedium, maxLines = 1) + } +} + +enum class PillTone { + Neutral, + Success, + Warning, + Destructive, + Brand, +} + +@Composable +fun ErrorBanner(message: String, modifier: Modifier = Modifier) { + val colors = LocalVniDropColors.current + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors(containerColor = colors.destructive.copy(alpha = 0.14f)), + border = BorderStroke(1.dp, colors.destructive.copy(alpha = 0.28f)), + ) { + Text( + text = message, + modifier = Modifier.padding(14.dp), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +@Composable +fun ProgressRow( + label: String, + progress: Float?, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(label, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (progress == null) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } else { + LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth()) + } + } +} + +@Composable +fun MetadataRow(label: String, value: String, modifier: Modifier = Modifier) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + text = label, + modifier = Modifier.weight(0.35f), + color = LocalVniDropColors.current.textMuted, + style = MaterialTheme.typography.bodySmall, + ) + Text( + text = value, + modifier = Modifier.weight(0.65f), + style = MaterialTheme.typography.bodySmall, + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt new file mode 100644 index 0000000..c26a275 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt @@ -0,0 +1,147 @@ +package com.vnidrop.app.ui.state + +import com.vnidrop.app.ui.theme.ThemeMode +import uniffi.vnidrop.CoreEvent +import uniffi.vnidrop.ReceiverRequest +import uniffi.vnidrop.StoredTransfer +import kotlin.math.roundToInt + +enum class AppDestination( + val label: String, +) { + Send("Send"), + Receive("Receive"), + Activity("Activity"), + Requests("Requests"), + Settings("Settings"), +} + +enum class WindowClass { + Compact, + Medium, + Expanded, +} + +fun windowClassFor(widthDp: Float): WindowClass = + when { + widthDp >= 920f -> WindowClass.Expanded + widthDp >= 640f -> WindowClass.Medium + else -> WindowClass.Compact + } + +data class AppUiState( + val destination: AppDestination = AppDestination.Send, + val themeMode: ThemeMode = ThemeMode.System, + val diagnosticsVisible: Boolean = false, +) + +data class SendUiState( + val selectedSource: String = "", + val selectedDisplayName: String = "", + val transferName: String = "VniDrop transfer", + val senderName: String = "", + val isSharing: Boolean = false, +) + +data class ReceiveUiState( + val ticket: String = "", + val outputDirectory: String = "", + val receiverName: String = "", + val isReceiving: Boolean = false, +) + +data class TransferProgress( + val transferId: ULong?, + val phase: String, + val label: String, + val progress: Float?, +) + +fun displayNameForStatus(status: String): String = + when (status.lowercase()) { + "sharing" -> "Sharing" + "receiving" -> "Receiving" + "done" -> "Done" + "cancelled" -> "Cancelled" + "stopped" -> "Stopped" + "failed" -> "Failed" + else -> status.replaceFirstChar { it.uppercase() } + } + +fun activeReceiverRequests(requests: List): List = + requests.filter { it.status == "requested" } + +fun summarizeProgress(events: List): List = + events + .filter { event -> event.transferId != null && event.phase in progressPhases } + .distinctBy { event -> "${event.transferId}:${event.phase}" } + .take(6) + .map { event -> + TransferProgress( + transferId = event.transferId, + phase = event.phase, + label = eventLabel(event), + progress = parseProgress(event.dataJson), + ) + } + +fun transferSubtitle(transfer: StoredTransfer): String { + val pieces = listOfNotNull( + transfer.transferName, + "${transfer.fileCount} file${if (transfer.fileCount == 1UL) "" else "s"}", + formatBytes(transfer.totalSize), + ) + return pieces.joinToString(" | ") +} + +fun formatBytes(size: ULong): String { + val value = size.toDouble() + val units = listOf("B", "KB", "MB", "GB", "TB") + var scaled = value + var unitIndex = 0 + while (scaled >= 1024.0 && unitIndex < units.lastIndex) { + scaled /= 1024.0 + unitIndex += 1 + } + return if (unitIndex == 0) { + "${size} ${units[unitIndex]}" + } else { + "${(scaled * 10).roundToInt() / 10.0} ${units[unitIndex]}" + } +} + +fun friendlyCoreError(raw: String?): String? { + if (raw.isNullOrBlank()) return null + return when { + raw.contains("failed to parse transfer ticket", ignoreCase = true) -> "The ticket could not be read. Check that the full ticket was copied." + raw.contains("permission", ignoreCase = true) || raw.contains("refused", ignoreCase = true) -> "The transfer is waiting for approval or was refused by the sender." + raw.contains("Failed to bind sockets", ignoreCase = true) -> "VniDrop could not open its network sockets on this device." + raw.contains("not found", ignoreCase = true) && raw.contains("libvnidrop", ignoreCase = true) -> "The native VniDrop library is missing from this build." + else -> raw + } +} + +private val progressPhases = setOf("import", "ticket", "access", "transfer", "download", "export", "lifecycle") + +private fun eventLabel(event: CoreEvent): String { + val direction = event.direction?.replaceFirstChar { it.uppercase() } + val phase = event.phase.replaceFirstChar { it.uppercase() } + val kind = event.kind.replace('-', ' ') + return listOfNotNull(direction, phase, kind).joinToString(" - ") +} + +private fun parseProgress(json: String): Float? { + val transferred = findNumber(json, "transferred") ?: findNumber(json, "downloaded") ?: findNumber(json, "written") + val total = findNumber(json, "total") ?: findNumber(json, "total_size") + if (transferred == null || total == null || total <= 0.0) return null + return (transferred / total).toFloat().coerceIn(0f, 1f) +} + +private fun findNumber(json: String, key: String): Double? { + val marker = "\"$key\":" + val start = json.indexOf(marker) + if (start < 0) return null + val valueStart = start + marker.length + val valueEnd = json.indexOfAny(charArrayOf(',', '}'), valueStart).takeIf { it >= 0 } ?: json.length + return json.substring(valueStart, valueEnd).trim().toDoubleOrNull() +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/theme/VniDropTheme.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/theme/VniDropTheme.kt new file mode 100644 index 0000000..cc3bd7d --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/theme/VniDropTheme.kt @@ -0,0 +1,158 @@ +package com.vnidrop.app.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +enum class ThemeMode { + System, + Light, + Dark, +} + +fun resolveDarkTheme(mode: ThemeMode, systemDark: Boolean): Boolean = + when (mode) { + ThemeMode.System -> systemDark + ThemeMode.Light -> false + ThemeMode.Dark -> true + } + +@Immutable +data class VniDropColors( + val canvas: Color, + val sidebar: Color, + val surface: Color, + val surfaceRaised: Color, + val surfaceMuted: Color, + val border: Color, + val borderStrong: Color, + val textPrimary: Color, + val textSecondary: Color, + val textMuted: Color, + val brand: Color, + val brandPressed: Color, + val warning: Color, + val destructive: Color, + val success: Color, +) + +val LocalVniDropColors = staticCompositionLocalOf { lightVniDropColors } + +private val lightVniDropColors = VniDropColors( + canvas = hsl(0f, 0f, 97.3f), + sidebar = hsl(0f, 0f, 98.8f), + surface = hsl(0f, 0f, 100f), + surfaceRaised = hsl(0f, 0f, 98.8f), + surfaceMuted = hsl(0f, 0f, 95.3f), + border = hsl(0f, 0f, 85.9f), + borderStrong = hsl(0f, 0f, 78f), + textPrimary = hsl(0f, 0f, 9f), + textSecondary = hsl(0f, 0f, 32.2f), + textMuted = hsl(0f, 0f, 43.9f), + brand = hsl(153.1f, 60.2f, 52.7f), + brandPressed = hsl(152.9f, 56.1f, 46.5f), + warning = hsl(38.9f, 100f, 57.1f), + destructive = hsl(10.2f, 77.9f, 53.9f), + success = hsl(153.1f, 60.2f, 40f), +) + +private val darkVniDropColors = VniDropColors( + canvas = hsl(0f, 0f, 7.1f), + sidebar = hsl(0f, 0f, 9f), + surface = hsl(0f, 0f, 12.2f), + surfaceRaised = hsl(0f, 0f, 14.1f), + surfaceMuted = hsl(0f, 0f, 16.1f), + border = hsl(0f, 0f, 24.3f), + borderStrong = hsl(0f, 0f, 31.4f), + textPrimary = hsl(0f, 0f, 98f), + textSecondary = hsl(0f, 0f, 70.6f), + textMuted = hsl(0f, 0f, 53.7f), + brand = hsl(153.1f, 60.2f, 52.7f), + brandPressed = hsl(152.9f, 56.1f, 46.5f), + warning = hsl(38.9f, 100f, 42.9f), + destructive = hsl(10.2f, 77.9f, 53.9f), + success = hsl(153.1f, 60.2f, 52.7f), +) + +private fun materialScheme(tokens: VniDropColors, dark: Boolean): ColorScheme { + val base = if (dark) { + darkColorScheme() + } else { + lightColorScheme() + } + return base.copy( + primary = tokens.brand, + onPrimary = if (dark) Color.Black else Color.White, + primaryContainer = tokens.surfaceMuted, + onPrimaryContainer = tokens.textPrimary, + background = tokens.canvas, + onBackground = tokens.textPrimary, + surface = tokens.surface, + onSurface = tokens.textPrimary, + surfaceVariant = tokens.surfaceMuted, + onSurfaceVariant = tokens.textSecondary, + outline = tokens.border, + outlineVariant = tokens.border, + error = tokens.destructive, + errorContainer = tokens.destructive.copy(alpha = if (dark) 0.22f else 0.16f), + onErrorContainer = tokens.textPrimary, + ) +} + +@Composable +fun VniDropTheme( + mode: ThemeMode, + content: @Composable () -> Unit, +) { + val dark = resolveDarkTheme(mode, isSystemInDarkTheme()) + val tokens = if (dark) darkVniDropColors else lightVniDropColors + androidx.compose.runtime.CompositionLocalProvider(LocalVniDropColors provides tokens) { + MaterialTheme( + colorScheme = materialScheme(tokens, dark), + content = content, + ) + } +} + +private fun hsl(hue: Float, saturation: Float, lightness: Float): Color { + val h = ((hue % 360f) + 360f) % 360f / 360f + val s = saturation.coerceIn(0f, 100f) / 100f + val l = lightness.coerceIn(0f, 100f) / 100f + if (s == 0f) { + return Color(l, l, l) + } + val q = if (l < 0.5f) l * (1 + s) else l + s - l * s + val p = 2 * l - q + return Color( + red = hueToRgb(p, q, h + 1f / 3f), + green = hueToRgb(p, q, h), + blue = hueToRgb(p, q, h - 1f / 3f), + ) +} + +private fun hueToRgb(p: Float, q: Float, input: Float): Float { + var t = input + if (t < 0f) t += 1f + if (t > 1f) t -= 1f + return when { + t < 1f / 6f -> p + (q - p) * 6f * t + t < 1f / 2f -> q + t < 2f / 3f -> p + (q - p) * (2f / 3f - t) * 6f + else -> p + }.let { min(1f, max(0f, it)) } +} + +fun Color.contrastAgainst(other: Color): Float = + abs(luminanceApproximation() - other.luminanceApproximation()) + +private fun Color.luminanceApproximation(): Float = + (red * 0.2126f) + (green * 0.7152f) + (blue * 0.0722f) diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt new file mode 100644 index 0000000..e575512 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt @@ -0,0 +1,43 @@ +package com.vnidrop.app.ui.state + +import com.vnidrop.app.ui.theme.ThemeMode +import com.vnidrop.app.ui.theme.resolveDarkTheme +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AppUiModelsTest { + @Test + fun windowClassUsesCompactMediumExpandedBreakpoints() { + assertEquals(WindowClass.Compact, windowClassFor(390f)) + assertEquals(WindowClass.Medium, windowClassFor(700f)) + assertEquals(WindowClass.Expanded, windowClassFor(1200f)) + } + + @Test + fun themeModeResolvesAgainstSystemOnlyWhenRequested() { + assertTrue(resolveDarkTheme(ThemeMode.System, systemDark = true)) + assertFalse(resolveDarkTheme(ThemeMode.System, systemDark = false)) + assertFalse(resolveDarkTheme(ThemeMode.Light, systemDark = true)) + assertTrue(resolveDarkTheme(ThemeMode.Dark, systemDark = false)) + } + + @Test + fun coreErrorsBecomeStableUserMessages() { + assertEquals( + "The ticket could not be read. Check that the full ticket was copied.", + friendlyCoreError("reason=failed to parse transfer ticket"), + ) + assertEquals( + "The transfer is waiting for approval or was refused by the sender.", + friendlyCoreError("permission denied by sender"), + ) + } + + @Test + fun byteFormattingKeepsTransferCardsReadable() { + assertEquals("58 B", formatBytes(58UL)) + assertEquals("1.5 KB", formatBytes(1536UL)) + } +}