mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
Build Rust core handshake and Compose UI foundation
This commit is contained in:
56
Cargo.lock
generated
56
Cargo.lock
generated
@@ -1334,6 +1334,20 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
@@ -1343,7 +1357,7 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi 6.0.0",
|
||||||
"rand_core 0.10.1",
|
"rand_core 0.10.1",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
@@ -2187,6 +2201,24 @@ dependencies = [
|
|||||||
"syn 2.0.118",
|
"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]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.18"
|
version = "1.0.18"
|
||||||
@@ -3291,6 +3323,12 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "5.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "r-efi"
|
name = "r-efi"
|
||||||
version = "6.0.0"
|
version = "6.0.0"
|
||||||
@@ -5025,6 +5063,7 @@ dependencies = [
|
|||||||
"iroh",
|
"iroh",
|
||||||
"iroh-blobs",
|
"iroh-blobs",
|
||||||
"irpc",
|
"irpc",
|
||||||
|
"irpc-iroh",
|
||||||
"libc",
|
"libc",
|
||||||
"n0-future",
|
"n0-future",
|
||||||
"num_cpus",
|
"num_cpus",
|
||||||
@@ -5067,6 +5106,15 @@ version = "0.11.1+wasi-snapshot-preview1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
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]]
|
[[package]]
|
||||||
name = "wasite"
|
name = "wasite"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -5588,6 +5636,12 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen"
|
||||||
|
version = "0.57.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wmi"
|
name = "wmi"
|
||||||
version = "0.18.4"
|
version = "0.18.4"
|
||||||
|
|||||||
50
crates/vnidrop/CORE_FLOW.md
Normal file
50
crates/vnidrop/CORE_FLOW.md
Normal file
@@ -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.
|
||||||
@@ -17,6 +17,7 @@ futures-lite = "2.6.1"
|
|||||||
iroh = "1.0.0"
|
iroh = "1.0.0"
|
||||||
iroh-blobs = "0.103.0"
|
iroh-blobs = "0.103.0"
|
||||||
irpc = "0.17.0"
|
irpc = "0.17.0"
|
||||||
|
irpc-iroh = "0.17.0"
|
||||||
libc = "0.2.186"
|
libc = "0.2.186"
|
||||||
n0-future = "0.3.1"
|
n0-future = "0.3.1"
|
||||||
num_cpus = "1.17.0"
|
num_cpus = "1.17.0"
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
use std::{
|
use std::{collections::HashMap, sync::Arc};
|
||||||
collections::{HashMap, HashSet},
|
|
||||||
sync::Arc,
|
|
||||||
};
|
|
||||||
|
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
use crate::api::TransferAccessMode;
|
use crate::api::TransferAccessMode;
|
||||||
|
use crate::util::now_ms;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub(crate) enum AccessDecision {
|
pub(crate) enum AccessDecision {
|
||||||
@@ -16,7 +14,7 @@ pub(crate) enum AccessDecision {
|
|||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub(crate) struct AccessPolicy {
|
pub(crate) struct AccessPolicy {
|
||||||
modes: RwLock<HashMap<u64, TransferAccessMode>>,
|
modes: RwLock<HashMap<u64, TransferAccessMode>>,
|
||||||
approved_sessions: RwLock<HashSet<(u64, String)>>,
|
approved_sessions: RwLock<HashMap<(u64, String), ApprovalSession>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AccessPolicy {
|
impl AccessPolicy {
|
||||||
@@ -33,14 +31,24 @@ impl AccessPolicy {
|
|||||||
self.approved_sessions
|
self.approved_sessions
|
||||||
.write()
|
.write()
|
||||||
.await
|
.await
|
||||||
.retain(|(id, _)| *id != transfer_id);
|
.retain(|(id, _), _| *id != transfer_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn approve_endpoint(&self, transfer_id: u64, endpoint_id: String) {
|
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<i64>,
|
||||||
|
) {
|
||||||
self.approved_sessions
|
self.approved_sessions
|
||||||
.write()
|
.write()
|
||||||
.await
|
.await
|
||||||
.insert((transfer_id, endpoint_id));
|
.insert((transfer_id, endpoint_id), ApprovalSession { expires_at });
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn decide(
|
pub(crate) async fn decide(
|
||||||
@@ -48,9 +56,6 @@ impl AccessPolicy {
|
|||||||
transfer_id: u64,
|
transfer_id: u64,
|
||||||
endpoint_id: Option<&str>,
|
endpoint_id: Option<&str>,
|
||||||
) -> AccessDecision {
|
) -> 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
|
match self
|
||||||
.modes
|
.modes
|
||||||
.read()
|
.read()
|
||||||
@@ -66,19 +71,32 @@ impl AccessPolicy {
|
|||||||
reason: "missing-endpoint-id",
|
reason: "missing-endpoint-id",
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
if self
|
let key = (transfer_id, endpoint_id.to_string());
|
||||||
.approved_sessions
|
let mut sessions = self.approved_sessions.write().await;
|
||||||
.read()
|
match sessions.get(&key) {
|
||||||
.await
|
Some(session) if session.is_valid(now_ms()) => AccessDecision::Allow,
|
||||||
.contains(&(transfer_id, endpoint_id.to_string()))
|
Some(_) => {
|
||||||
{
|
sessions.remove(&key);
|
||||||
AccessDecision::Allow
|
AccessDecision::Deny {
|
||||||
} else {
|
reason: "approval-expired",
|
||||||
AccessDecision::Deny {
|
}
|
||||||
reason: "approval-required",
|
|
||||||
}
|
}
|
||||||
|
None => AccessDecision::Deny {
|
||||||
|
reason: "approval-required",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct ApprovalSession {
|
||||||
|
expires_at: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApprovalSession {
|
||||||
|
fn is_valid(&self, now: i64) -> bool {
|
||||||
|
self.expires_at.is_none_or(|expires_at| expires_at >= now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -122,3 +122,18 @@ pub struct TicketInspection {
|
|||||||
pub blob_ticket: String,
|
pub blob_ticket: String,
|
||||||
pub metadata: Option<TransferMetadata>,
|
pub metadata: Option<TransferMetadata>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
pub receiver_device_name: Option<String>,
|
||||||
|
pub app_version: String,
|
||||||
|
pub status: String,
|
||||||
|
pub reason: Option<String>,
|
||||||
|
pub requested_at: i64,
|
||||||
|
pub responded_at: Option<i64>,
|
||||||
|
}
|
||||||
|
|||||||
221
crates/vnidrop/src/approval.rs
Normal file
221
crates/vnidrop/src/approval.rs
Normal file
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct ApprovalService {
|
||||||
|
repository: Repository,
|
||||||
|
event_hub: Arc<EventHub>,
|
||||||
|
access_policy: Arc<AccessPolicy>,
|
||||||
|
pending: Arc<Mutex<HashMap<String, oneshot::Sender<ApprovalDecision>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApprovalService {
|
||||||
|
pub(crate) fn new(
|
||||||
|
repository: Repository,
|
||||||
|
event_hub: Arc<EventHub>,
|
||||||
|
access_policy: Arc<AccessPolicy>,
|
||||||
|
) -> 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<String>,
|
||||||
|
) -> 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<String>,
|
||||||
|
) -> 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
120
crates/vnidrop/src/handshake.rs
Normal file
120
crates/vnidrop/src/handshake.rs
Normal file
@@ -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::<HandshakeProtocol>(&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<HandshakeProtocol>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HandshakeClient {
|
||||||
|
pub(crate) async fn request_transfer(
|
||||||
|
&self,
|
||||||
|
metadata: &TransferMetadata,
|
||||||
|
receiver_name: Option<&str>,
|
||||||
|
) -> Result<HandshakeResponse, irpc::Error> {
|
||||||
|
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<String>,
|
||||||
|
pub(crate) receiver_device_name: Option<String>,
|
||||||
|
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<HandshakeResponse>)]
|
||||||
|
RequestTransfer(RequestTransfer),
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
mod access_policy;
|
mod access_policy;
|
||||||
mod api;
|
mod api;
|
||||||
|
mod approval;
|
||||||
mod error;
|
mod error;
|
||||||
mod event_hub;
|
mod event_hub;
|
||||||
mod filesystem;
|
mod filesystem;
|
||||||
|
mod handshake;
|
||||||
mod logging;
|
mod logging;
|
||||||
mod repository;
|
mod repository;
|
||||||
mod runtime;
|
mod runtime;
|
||||||
@@ -11,8 +13,9 @@ mod ticket;
|
|||||||
mod util;
|
mod util;
|
||||||
|
|
||||||
pub use api::{
|
pub use api::{
|
||||||
CoreEvent, CoreEventSink, RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource,
|
CoreEvent, CoreEventSink, ReceiverRequest, RuntimeStatus, ShareMetadataInput, ShareResult,
|
||||||
SourceKind, StoredTransfer, TicketInspection, TransferAccessMode, TransferMetadata,
|
ShareSource, SourceKind, StoredTransfer, TicketInspection, TransferAccessMode,
|
||||||
|
TransferMetadata,
|
||||||
};
|
};
|
||||||
pub use error::VnidropError;
|
pub use error::VnidropError;
|
||||||
pub use runtime::VnidropCore;
|
pub use runtime::VnidropCore;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use sqlx::{
|
|||||||
Row, SqlitePool,
|
Row, SqlitePool,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::api::{CoreEvent, StoredTransfer};
|
use crate::api::{CoreEvent, ReceiverRequest, StoredTransfer};
|
||||||
use crate::util::now_ms;
|
use crate::util::now_ms;
|
||||||
|
|
||||||
const SCHEMA_VERSION: i64 = 1;
|
const SCHEMA_VERSION: i64 = 1;
|
||||||
@@ -27,6 +27,16 @@ pub(crate) struct TransferUpsert<'a> {
|
|||||||
pub(crate) total_size: u64,
|
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 {
|
impl Repository {
|
||||||
pub(crate) async fn open(app_data_dir: &Path) -> Result<Self> {
|
pub(crate) async fn open(app_data_dir: &Path) -> Result<Self> {
|
||||||
let db_path = app_data_dir.join("vnidrop.sqlite3");
|
let db_path = app_data_dir.join("vnidrop.sqlite3");
|
||||||
@@ -87,6 +97,32 @@ impl Repository {
|
|||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.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}"))
|
sqlx::query(&format!("PRAGMA user_version = {SCHEMA_VERSION}"))
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -171,6 +207,98 @@ impl Repository {
|
|||||||
Ok(())
|
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<Vec<ReceiverRequest>> {
|
||||||
|
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<bool> {
|
||||||
|
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::<i64, _>(0) != 0)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn list_transfers(&self) -> Result<Vec<StoredTransfer>> {
|
pub(crate) async fn list_transfers(&self) -> Result<Vec<StoredTransfer>> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
@@ -243,3 +371,19 @@ fn row_to_event(row: sqlx::sqlite::SqliteRow) -> CoreEvent {
|
|||||||
data_json: row.get("data_json"),
|
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::<i64, _>("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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,9 +32,10 @@ use tokio::{
|
|||||||
use crate::{
|
use crate::{
|
||||||
access_policy::{AccessDecision, AccessPolicy},
|
access_policy::{AccessDecision, AccessPolicy},
|
||||||
api::{
|
api::{
|
||||||
CoreEvent, CoreEventSink, RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource,
|
CoreEvent, CoreEventSink, ReceiverRequest, RuntimeStatus, ShareMetadataInput, ShareResult,
|
||||||
StoredTransfer, TicketInspection, TransferAccessMode, TransferMetadata,
|
ShareSource, StoredTransfer, TicketInspection, TransferAccessMode, TransferMetadata,
|
||||||
},
|
},
|
||||||
|
approval::ApprovalService,
|
||||||
error::VnidropError,
|
error::VnidropError,
|
||||||
event_hub::EventHub,
|
event_hub::EventHub,
|
||||||
filesystem::{
|
filesystem::{
|
||||||
@@ -42,6 +43,7 @@ use crate::{
|
|||||||
read_stream_from_blocking_reader, safe_output_path, wait_for_writer,
|
read_stream_from_blocking_reader, safe_output_path, wait_for_writer,
|
||||||
write_stream_to_blocking_writer, TransferImport,
|
write_stream_to_blocking_writer, TransferImport,
|
||||||
},
|
},
|
||||||
|
handshake::{HandshakeResponse, HandshakeService},
|
||||||
logging::init_logging,
|
logging::init_logging,
|
||||||
repository::{Repository, TransferUpsert},
|
repository::{Repository, TransferUpsert},
|
||||||
secret::load_or_create_secret,
|
secret::load_or_create_secret,
|
||||||
@@ -69,7 +71,8 @@ struct CoreInner {
|
|||||||
router: Router,
|
router: Router,
|
||||||
store: FsStore,
|
store: FsStore,
|
||||||
repository: Repository,
|
repository: Repository,
|
||||||
event_hub: EventHub,
|
event_hub: Arc<EventHub>,
|
||||||
|
approval: ApprovalService,
|
||||||
access_policy: Arc<AccessPolicy>,
|
access_policy: Arc<AccessPolicy>,
|
||||||
active_transfers: TokioMutex<HashMap<u64, oneshot::Sender<()>>>,
|
active_transfers: TokioMutex<HashMap<u64, oneshot::Sender<()>>>,
|
||||||
active_shares: TokioMutex<HashMap<u64, TempTag>>,
|
active_shares: TokioMutex<HashMap<u64, TempTag>>,
|
||||||
@@ -165,6 +168,26 @@ impl VnidropCore {
|
|||||||
.map_err(VnidropError::permission)
|
.map_err(VnidropError::permission)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn list_receiver_requests(
|
||||||
|
&self,
|
||||||
|
transfer_id: u64,
|
||||||
|
) -> Result<Vec<ReceiverRequest>, 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<String>,
|
||||||
|
) -> Result<(), VnidropError> {
|
||||||
|
self.runtime
|
||||||
|
.block_on(self.inner.approval.respond(request_id, accepted, reason))
|
||||||
|
.map_err(VnidropError::permission)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn list_transfers(&self) -> Result<Vec<StoredTransfer>, VnidropError> {
|
pub fn list_transfers(&self) -> Result<Vec<StoredTransfer>, VnidropError> {
|
||||||
self.runtime
|
self.runtime
|
||||||
.block_on(self.inner.repository.list_transfers())
|
.block_on(self.inner.repository.list_transfers())
|
||||||
@@ -215,10 +238,15 @@ impl CoreInner {
|
|||||||
// uses them for send progress and for the current approval gate.
|
// uses them for send progress and for the current approval gate.
|
||||||
let (events, event_rx) = EventSender::channel(128, EventMask::ALL_READONLY);
|
let (events, event_rx) = EventSender::channel(128, EventMask::ALL_READONLY);
|
||||||
let blobs = BlobsProtocol::new(&store, Some(events));
|
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())
|
let router = Router::builder(endpoint.clone())
|
||||||
.accept(iroh_blobs::ALPN, blobs)
|
.accept(iroh_blobs::ALPN, blobs)
|
||||||
|
.accept(HandshakeService::ALPN, handshake)
|
||||||
.spawn();
|
.spawn();
|
||||||
let event_hub = EventHub::start(repository.clone(), event_sink);
|
|
||||||
|
|
||||||
let inner = Arc::new(Self {
|
let inner = Arc::new(Self {
|
||||||
endpoint,
|
endpoint,
|
||||||
@@ -226,7 +254,8 @@ impl CoreInner {
|
|||||||
store,
|
store,
|
||||||
repository,
|
repository,
|
||||||
event_hub,
|
event_hub,
|
||||||
access_policy: AccessPolicy::new(),
|
approval,
|
||||||
|
access_policy,
|
||||||
active_transfers: TokioMutex::new(HashMap::new()),
|
active_transfers: TokioMutex::new(HashMap::new()),
|
||||||
active_shares: TokioMutex::new(HashMap::new()),
|
active_shares: TokioMutex::new(HashMap::new()),
|
||||||
hash_to_transfer: TokioMutex::new(HashMap::new()),
|
hash_to_transfer: TokioMutex::new(HashMap::new()),
|
||||||
@@ -320,7 +349,7 @@ impl CoreInner {
|
|||||||
.await
|
.await
|
||||||
.insert(import.root_hash.to_string(), metadata.transfer_id);
|
.insert(import.root_hash.to_string(), metadata.transfer_id);
|
||||||
self.access_policy
|
self.access_policy
|
||||||
.set_mode(metadata.transfer_id, TransferAccessMode::Public)
|
.set_mode(metadata.transfer_id, TransferAccessMode::ApprovalRequired)
|
||||||
.await;
|
.await;
|
||||||
self.active_shares
|
self.active_shares
|
||||||
.lock()
|
.lock()
|
||||||
@@ -464,6 +493,15 @@ impl CoreInner {
|
|||||||
tokio::fs::create_dir_all(&output_dir).await?;
|
tokio::fs::create_dir_all(&output_dir).await?;
|
||||||
|
|
||||||
self.emit_transfer(transfer_id, "receive", "network", "connecting", json!({}));
|
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
|
let connection = self
|
||||||
.endpoint
|
.endpoint
|
||||||
.connect(parsed.blob_ticket.addr().clone(), iroh_blobs::ALPN)
|
.connect(parsed.blob_ticket.addr().clone(), iroh_blobs::ALPN)
|
||||||
@@ -583,6 +621,49 @@ impl CoreInner {
|
|||||||
Ok(())
|
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) {
|
async fn shutdown(&self) {
|
||||||
if self.shutdown_started.swap(true, Ordering::SeqCst) {
|
if self.shutdown_started.swap(true, Ordering::SeqCst) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ mod tests {
|
|||||||
collect_import_files, default_collection_name, path_to_string,
|
collect_import_files, default_collection_name, path_to_string,
|
||||||
percent_decode_file_url_path, validated_relative_string,
|
percent_decode_file_url_path, validated_relative_string,
|
||||||
},
|
},
|
||||||
repository::Repository,
|
repository::{ReceiverRequestInsert, Repository},
|
||||||
runtime::VnidropCore,
|
runtime::VnidropCore,
|
||||||
secret::load_or_create_secret,
|
secret::load_or_create_secret,
|
||||||
ticket::{parse_transfer_ticket, VnidropTicket},
|
ticket::{parse_transfer_ticket, VnidropTicket},
|
||||||
@@ -292,6 +292,42 @@ mod tests {
|
|||||||
assert_eq!(events[0].id, "event-1");
|
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]
|
#[tokio::test]
|
||||||
async fn access_policy_requires_approved_endpoint_when_locked() {
|
async fn access_policy_requires_approved_endpoint_when_locked() {
|
||||||
let policy = AccessPolicy::new();
|
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]
|
#[test]
|
||||||
fn invalid_receive_ticket_is_typed_and_persisted_as_event() {
|
fn invalid_receive_ticket_is_typed_and_persisted_as_event() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::{
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
use vnidrop::{
|
use vnidrop::{
|
||||||
CoreEvent, CoreEventSink, ShareMetadataInput, ShareSource, SourceKind, TransferAccessMode,
|
CoreEvent, CoreEventSink, ReceiverRequest, ShareMetadataInput, ShareSource, SourceKind,
|
||||||
VnidropCore,
|
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<VnidropCore>,
|
||||||
|
ticket: String,
|
||||||
|
output_dir: String,
|
||||||
|
receiver_name: Option<String>,
|
||||||
|
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]
|
#[test]
|
||||||
fn two_local_cores_transfer_file() {
|
fn two_local_cores_transfer_file() {
|
||||||
let sender_dir = tempfile::tempdir().unwrap();
|
let sender_dir = tempfile::tempdir().unwrap();
|
||||||
@@ -63,13 +109,16 @@ fn two_local_cores_transfer_file() {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
receiver
|
receive_with_response(
|
||||||
.receive(
|
&sender,
|
||||||
share.ticket,
|
share.transfer_id,
|
||||||
output_dir.path().to_string_lossy().to_string(),
|
receiver.clone(),
|
||||||
Some("receiver".to_string()),
|
share.ticket,
|
||||||
)
|
output_dir.path().to_string_lossy().to_string(),
|
||||||
.unwrap();
|
Some("receiver".to_string()),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
std::fs::read(output_dir.path().join("hello.txt")).unwrap(),
|
std::fs::read(output_dir.path().join("hello.txt")).unwrap(),
|
||||||
@@ -121,13 +170,16 @@ fn two_local_cores_transfer_directory() {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
receiver
|
receive_with_response(
|
||||||
.receive(
|
&sender,
|
||||||
share.ticket,
|
share.transfer_id,
|
||||||
output_dir.path().to_string_lossy().to_string(),
|
receiver.clone(),
|
||||||
Some("receiver".to_string()),
|
share.ticket,
|
||||||
)
|
output_dir.path().to_string_lossy().to_string(),
|
||||||
.unwrap();
|
Some("receiver".to_string()),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
std::fs::read(output_dir.path().join("photos").join("cover.txt")).unwrap(),
|
std::fs::read(output_dir.path().join("photos").join("cover.txt")).unwrap(),
|
||||||
@@ -189,32 +241,31 @@ fn approval_required_denies_then_allows_receiver() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
sender
|
assert!(receive_with_response(
|
||||||
.set_transfer_access_mode(share.transfer_id, TransferAccessMode::ApprovalRequired)
|
&sender,
|
||||||
.unwrap();
|
share.transfer_id,
|
||||||
|
receiver.clone(),
|
||||||
assert!(receiver
|
share.ticket.clone(),
|
||||||
.receive(
|
denied_output.path().to_string_lossy().to_string(),
|
||||||
share.ticket.clone(),
|
Some("receiver".to_string()),
|
||||||
denied_output.path().to_string_lossy().to_string(),
|
false,
|
||||||
Some("receiver".to_string()),
|
)
|
||||||
)
|
.is_err());
|
||||||
.is_err());
|
|
||||||
assert!(sender_sink
|
assert!(sender_sink
|
||||||
.events()
|
.events()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|event| event.phase == "access" && event.kind == "request-denied"));
|
.any(|event| event.phase == "approval" && event.kind == "receiver-refused"));
|
||||||
|
|
||||||
sender
|
receive_with_response(
|
||||||
.approve_endpoint_for_transfer(share.transfer_id, receiver.status().endpoint_id)
|
&sender,
|
||||||
.unwrap();
|
share.transfer_id,
|
||||||
receiver
|
receiver.clone(),
|
||||||
.receive(
|
share.ticket,
|
||||||
share.ticket,
|
allowed_output.path().to_string_lossy().to_string(),
|
||||||
allowed_output.path().to_string_lossy().to_string(),
|
Some("receiver".to_string()),
|
||||||
Some("receiver".to_string()),
|
true,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
std::fs::read(allowed_output.path().join("private.txt")).unwrap(),
|
std::fs::read(allowed_output.path().join("private.txt")).unwrap(),
|
||||||
b"approved content"
|
b"approved content"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
@file:OptIn(gobley.gradle.InternalGobleyGradleApi::class)
|
@file:OptIn(gobley.gradle.InternalGobleyGradleApi::class)
|
||||||
|
|
||||||
|
import gobley.gradle.cargo.dsl.appleMobile
|
||||||
import gobley.gradle.rust.targets.RustAndroidTarget
|
import gobley.gradle.rust.targets.RustAndroidTarget
|
||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
@@ -76,6 +77,15 @@ cargo {
|
|||||||
packageDirectory = layout.projectDirectory.dir("../crates/vnidrop")
|
packageDirectory = layout.projectDirectory.dir("../crates/vnidrop")
|
||||||
publishJvmArtifacts = true
|
publishJvmArtifacts = true
|
||||||
androidTargetsToBuild.set(setOf(RustAndroidTarget.Arm64))
|
androidTargetsToBuild.set(setOf(RustAndroidTarget.Arm64))
|
||||||
|
builds.appleMobile {
|
||||||
|
variants {
|
||||||
|
buildTaskProvider.configure {
|
||||||
|
if (rustTarget.cinteropName == "ios") {
|
||||||
|
additionalEnvironment.put("IPHONEOS_DEPLOYMENT_TARGET", "16.0.0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
uniffi {
|
uniffi {
|
||||||
|
|||||||
@@ -1,228 +1,643 @@
|
|||||||
package com.vnidrop.app
|
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.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.safeContentPadding
|
import androidx.compose.foundation.layout.safeContentPadding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
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.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.RadioButton
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
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.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
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.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vnidrop.app.core.CoreRepository
|
import com.vnidrop.app.core.CoreRepository
|
||||||
|
import com.vnidrop.app.core.CoreUiState
|
||||||
import com.vnidrop.app.core.PickedShareFile
|
import com.vnidrop.app.core.PickedShareFile
|
||||||
import com.vnidrop.app.core.rememberShareFilePicker
|
import com.vnidrop.app.core.rememberShareFilePicker
|
||||||
import com.vnidrop.app.core.sharePickedFile
|
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 kotlinx.coroutines.launch
|
||||||
|
import uniffi.vnidrop.CoreEvent
|
||||||
|
import uniffi.vnidrop.ReceiverRequest
|
||||||
|
import uniffi.vnidrop.ShareResult
|
||||||
|
import uniffi.vnidrop.StoredTransfer
|
||||||
|
import uniffi.vnidrop.TicketInspection
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@Preview
|
@Preview
|
||||||
fun App() {
|
fun App() {
|
||||||
val platform = remember { getPlatform() }
|
val platform = remember { getPlatform() }
|
||||||
val repository = remember { CoreRepository() }
|
val repository = remember { CoreRepository() }
|
||||||
val state by repository.state.collectAsState()
|
val coreState by repository.state.collectAsState()
|
||||||
val scope = rememberCoroutineScope()
|
var appState by remember { mutableStateOf(AppUiState()) }
|
||||||
val clipboardManager = LocalClipboardManager.current
|
|
||||||
var appDataDir by remember { mutableStateOf(platform.defaultCoreDataDir) }
|
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<PickedShareFile?>(null) }
|
var selectedFile by remember { mutableStateOf<PickedShareFile?>(null) }
|
||||||
var transferName by remember { mutableStateOf("VniDrop transfer") }
|
val scope = rememberCoroutineScope()
|
||||||
var senderName by remember { mutableStateOf("") }
|
val clipboard = LocalClipboardManager.current
|
||||||
var ticket by remember { mutableStateOf("") }
|
val picker = rememberShareFilePicker(
|
||||||
var receiveDir by remember { mutableStateOf(platform.defaultReceiveDir) }
|
|
||||||
var receiverName by remember { mutableStateOf("") }
|
|
||||||
val shareFilePicker = rememberShareFilePicker(
|
|
||||||
onFilePicked = { file ->
|
onFilePicked = { file ->
|
||||||
selectedFile = file
|
selectedFile = file
|
||||||
sourcePath = file.value
|
sendState = sendState.copy(
|
||||||
if (transferName.isBlank() || transferName == "VniDrop transfer") {
|
selectedSource = file.value,
|
||||||
transferName = file.displayName
|
selectedDisplayName = file.displayName,
|
||||||
}
|
transferName = if (sendState.transferName == "VniDrop transfer" || sendState.transferName.isBlank()) {
|
||||||
|
file.displayName
|
||||||
|
} else {
|
||||||
|
sendState.transferName
|
||||||
|
},
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onError = { error -> scope.launch { repository.setError(error) } },
|
onError = { error -> scope.launch { repository.setError(error) } },
|
||||||
)
|
)
|
||||||
|
|
||||||
MaterialTheme {
|
LaunchedEffect(coreState.lastShare?.transferId) {
|
||||||
Surface(
|
coreState.lastShare?.let { share -> repository.refreshReceiverRequests(share.transferId) }
|
||||||
modifier = Modifier
|
}
|
||||||
.safeContentPadding()
|
|
||||||
.fillMaxSize()
|
VniDropTheme(mode = appState.themeMode) {
|
||||||
) {
|
BoxWithConstraints {
|
||||||
LazyColumn(
|
val windowClass = windowClassFor(maxWidth.value)
|
||||||
modifier = Modifier
|
AppFrame(
|
||||||
.fillMaxSize()
|
appState = appState,
|
||||||
.padding(16.dp),
|
coreState = coreState,
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
windowClass = windowClass,
|
||||||
|
onDestinationChange = { appState = appState.copy(destination = it) },
|
||||||
) {
|
) {
|
||||||
item {
|
when (appState.destination) {
|
||||||
Text("VniDrop Core", style = MaterialTheme.typography.headlineMedium)
|
AppDestination.Send -> SendScreen(
|
||||||
Text(platform.name, style = MaterialTheme.typography.bodySmall)
|
coreState = coreState,
|
||||||
Text(state.status, style = MaterialTheme.typography.bodyMedium)
|
sendState = sendState,
|
||||||
}
|
onSendStateChange = { sendState = it },
|
||||||
|
onSelectFile = { picker.pickFile() },
|
||||||
item {
|
onCreateShare = {
|
||||||
CoreCard(title = "Initialize") {
|
scope.launch {
|
||||||
OutlinedTextField(
|
sendState = sendState.copy(isSharing = true)
|
||||||
value = appDataDir,
|
val file = selectedFile
|
||||||
onValueChange = { appDataDir = it },
|
if (file != null) {
|
||||||
label = { Text("Core data directory") },
|
sharePickedFile(repository, file, sendState.transferName, sendState.senderName)
|
||||||
modifier = Modifier.fillMaxWidth(),
|
} else {
|
||||||
)
|
repository.sharePath(sendState.selectedSource, sendState.transferName, sendState.senderName)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
sendState = sendState.copy(isSharing = false)
|
||||||
) {
|
|
||||||
Text("Create share ticket")
|
|
||||||
}
|
|
||||||
state.lastShare?.let { share ->
|
|
||||||
Text("Ticket")
|
|
||||||
SelectionContainer {
|
|
||||||
Text(share.ticket, style = MaterialTheme.typography.bodySmall)
|
|
||||||
}
|
}
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
},
|
||||||
Button(onClick = {
|
onCopyTicket = { ticket -> clipboard.setText(AnnotatedString(ticket)) },
|
||||||
clipboardManager.setText(AnnotatedString(share.ticket))
|
onUseLocally = { ticket ->
|
||||||
}) {
|
receiveState = receiveState.copy(ticket = ticket)
|
||||||
Text("Copy ticket")
|
appState = appState.copy(destination = AppDestination.Receive)
|
||||||
}
|
},
|
||||||
Button(onClick = { ticket = share.ticket }) {
|
onRefreshRequests = { transferId -> scope.launch { repository.refreshReceiverRequests(transferId) } },
|
||||||
Text("Use locally")
|
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 {
|
if (appState.diagnosticsVisible) {
|
||||||
CoreCard(title = "Receive") {
|
DiagnosticsPanel(events = coreState.events)
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
state.error?.let { error ->
|
@Composable
|
||||||
item {
|
private fun AppFrame(
|
||||||
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) {
|
appState: AppUiState,
|
||||||
Text(
|
coreState: CoreUiState,
|
||||||
text = error,
|
windowClass: WindowClass,
|
||||||
modifier = Modifier.padding(12.dp),
|
onDestinationChange: (AppDestination) -> Unit,
|
||||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
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 {
|
@Composable
|
||||||
Text("Events", style = MaterialTheme.typography.titleMedium)
|
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<ReceiverRequest>,
|
||||||
|
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(
|
ProgressSection(coreState)
|
||||||
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,
|
|
||||||
)
|
@Composable
|
||||||
Text(
|
private fun TransferRow(transfer: StoredTransfer, onCancel: (ULong) -> Unit) {
|
||||||
text = event.dataJson,
|
val status = displayNameForStatus(transfer.status)
|
||||||
modifier = Modifier.padding(start = 12.dp, bottom = 10.dp, end = 12.dp),
|
val tone = when (transfer.status.lowercase()) {
|
||||||
style = MaterialTheme.typography.bodySmall,
|
"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<ReceiverRequest>,
|
||||||
|
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<ReceiverRequest>,
|
||||||
|
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
|
@Composable
|
||||||
private fun CoreCard(
|
private fun SettingsScreen(
|
||||||
title: String,
|
platformName: String,
|
||||||
content: @Composable () -> Unit,
|
appDataDir: String,
|
||||||
|
onAppDataDirChange: (String) -> Unit,
|
||||||
|
coreState: CoreUiState,
|
||||||
|
themeMode: ThemeMode,
|
||||||
|
onThemeModeChange: (ThemeMode) -> Unit,
|
||||||
|
diagnosticsVisible: Boolean,
|
||||||
|
onDiagnosticsVisibleChange: (Boolean) -> Unit,
|
||||||
|
onInitialize: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
Column(
|
ScreenHeader("Settings", "Configure the local node and app appearance.")
|
||||||
modifier = Modifier.padding(12.dp),
|
ErrorSection(coreState)
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
AppCard(title = "Node") {
|
||||||
) {
|
MetadataRow("Platform", platformName)
|
||||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
MetadataRow("Status", coreState.status)
|
||||||
HorizontalDivider()
|
Field(value = appDataDir, onValueChange = onAppDataDirChange, label = "Core data directory")
|
||||||
content()
|
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<CoreEvent>) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ import kotlinx.coroutines.withContext
|
|||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
import uniffi.vnidrop.CoreEvent
|
import uniffi.vnidrop.CoreEvent
|
||||||
import uniffi.vnidrop.CoreEventSink
|
import uniffi.vnidrop.CoreEventSink
|
||||||
|
import uniffi.vnidrop.ReceiverRequest
|
||||||
import uniffi.vnidrop.ShareMetadataInput
|
import uniffi.vnidrop.ShareMetadataInput
|
||||||
import uniffi.vnidrop.ShareResult
|
import uniffi.vnidrop.ShareResult
|
||||||
import uniffi.vnidrop.ShareSource
|
import uniffi.vnidrop.ShareSource
|
||||||
import uniffi.vnidrop.SourceKind
|
import uniffi.vnidrop.SourceKind
|
||||||
|
import uniffi.vnidrop.StoredTransfer
|
||||||
import uniffi.vnidrop.TicketInspection
|
import uniffi.vnidrop.TicketInspection
|
||||||
import uniffi.vnidrop.VnidropCore
|
import uniffi.vnidrop.VnidropCore
|
||||||
|
|
||||||
@@ -21,8 +23,10 @@ data class CoreUiState(
|
|||||||
val isInitialized: Boolean = false,
|
val isInitialized: Boolean = false,
|
||||||
val status: String = "Not initialized",
|
val status: String = "Not initialized",
|
||||||
val events: List<CoreEvent> = emptyList(),
|
val events: List<CoreEvent> = emptyList(),
|
||||||
|
val transfers: List<StoredTransfer> = emptyList(),
|
||||||
val lastShare: ShareResult? = null,
|
val lastShare: ShareResult? = null,
|
||||||
val lastInspection: TicketInspection? = null,
|
val lastInspection: TicketInspection? = null,
|
||||||
|
val receiverRequests: List<ReceiverRequest> = emptyList(),
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -46,6 +50,8 @@ class CoreRepository(
|
|||||||
core?.shutdown()
|
core?.shutdown()
|
||||||
core = VnidropCore.initialize(appDataDir, sink)
|
core = VnidropCore.initialize(appDataDir, sink)
|
||||||
refreshStatus()
|
refreshStatus()
|
||||||
|
loadTransfers()
|
||||||
|
loadEvents()
|
||||||
_state.update { it.copy(isInitialized = true, error = null) }
|
_state.update { it.copy(isInitialized = true, error = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +123,7 @@ class CoreRepository(
|
|||||||
suspend fun receive(ticket: String, outputDir: String, receiverName: String) = runCore {
|
suspend fun receive(ticket: String, outputDir: String, receiverName: String) = runCore {
|
||||||
requireCore().receive(ticket, outputDir, receiverName.ifBlank { null })
|
requireCore().receive(ticket, outputDir, receiverName.ifBlank { null })
|
||||||
refreshStatus()
|
refreshStatus()
|
||||||
|
loadTransfers()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun receiveIntoSecurityScopedDirectory(
|
suspend fun receiveIntoSecurityScopedDirectory(
|
||||||
@@ -128,11 +135,34 @@ class CoreRepository(
|
|||||||
requireCore().receive(ticket, outputDirectoryUrl, receiverName.ifBlank { null })
|
requireCore().receive(ticket, outputDirectoryUrl, receiverName.ifBlank { null })
|
||||||
}
|
}
|
||||||
refreshStatus()
|
refreshStatus()
|
||||||
|
loadTransfers()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun cancel(transferId: ULong) = runCore {
|
suspend fun cancel(transferId: ULong) = runCore {
|
||||||
requireCore().cancelTransfer(transferId)
|
requireCore().cancelTransfer(transferId)
|
||||||
refreshStatus()
|
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) {
|
suspend fun setError(message: String) {
|
||||||
@@ -166,9 +196,10 @@ class CoreRepository(
|
|||||||
senderName = senderName.ifBlank { null },
|
senderName = senderName.ifBlank { null },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
_state.update { it.copy(lastShare = result, error = null) }
|
_state.update { it.copy(lastShare = result, receiverRequests = emptyList(), error = null) }
|
||||||
}
|
}
|
||||||
refreshStatus()
|
refreshStatus()
|
||||||
|
loadTransfers()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun <T> withPlatformPathAccess(
|
private suspend fun <T> 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 =
|
private fun nextTransferId(): ULong =
|
||||||
Random.nextLong(1, Long.MAX_VALUE).toULong()
|
Random.nextLong(1, Long.MAX_VALUE).toULong()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ReceiverRequest>): List<ReceiverRequest> =
|
||||||
|
requests.filter { it.status == "requested" }
|
||||||
|
|
||||||
|
fun summarizeProgress(events: List<CoreEvent>): List<TransferProgress> =
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user