Add mobile file access sources

This commit is contained in:
2026-07-03 00:24:25 +02:00
parent 3da576a94e
commit 3cdbf7e8f9
11 changed files with 272 additions and 29 deletions

1
Cargo.lock generated
View File

@@ -5025,6 +5025,7 @@ dependencies = [
"iroh", "iroh",
"iroh-blobs", "iroh-blobs",
"irpc", "irpc",
"libc",
"n0-future", "n0-future",
"num_cpus", "num_cpus",
"serde", "serde",

View File

@@ -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"
libc = "0.2.186"
n0-future = "0.3.1" n0-future = "0.3.1"
num_cpus = "1.17.0" num_cpus = "1.17.0"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }

View File

@@ -31,6 +31,7 @@ pub struct RuntimeStatus {
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Enum)] #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Enum)]
pub enum SourceKind { pub enum SourceKind {
Path, Path,
FileDescriptor,
AndroidContentUri, AndroidContentUri,
IosSecurityScopedUrl, IosSecurityScopedUrl,
} }

View File

@@ -1,4 +1,7 @@
#[cfg(unix)]
use std::os::fd::{FromRawFd, OwnedFd};
use std::{ use std::{
fs::File,
io::{self, Read, Write}, io::{self, Read, Write},
path::{Component, Path, PathBuf}, path::{Component, Path, PathBuf},
}; };
@@ -25,15 +28,38 @@ pub(crate) struct TransferImport {
#[derive(Debug)] #[derive(Debug)]
pub(crate) struct ImportSourceFile { pub(crate) struct ImportSourceFile {
pub(crate) path: PathBuf, pub(crate) source: ImportSource,
pub(crate) collection_name: String, pub(crate) collection_name: String,
} }
#[derive(Debug)]
pub(crate) enum ImportSource {
Path(PathBuf),
#[cfg(unix)]
FileDescriptor(OwnedFd),
}
impl ImportSource {
pub(crate) fn open(self) -> Result<File> {
match self {
Self::Path(path) => {
File::open(&path).with_context(|| format!("failed to open {}", path.display()))
}
#[cfg(unix)]
Self::FileDescriptor(fd) => Ok(File::from(fd)),
}
}
}
pub(crate) fn collect_import_files(sources: Vec<ShareSource>) -> Result<Vec<ImportSourceFile>> { pub(crate) fn collect_import_files(sources: Vec<ShareSource>) -> Result<Vec<ImportSourceFile>> {
let mut files = Vec::new(); let mut files = Vec::new();
for source in sources { for source in sources {
match source.kind { match source.kind {
SourceKind::Path | SourceKind::IosSecurityScopedUrl => { SourceKind::Path | SourceKind::IosSecurityScopedUrl => {
// iOS security-scoped resources are still ordinary paths once
// the platform side has started the lease. Rust deliberately
// does not try to own that lease; it only streams while Kotlin
// keeps the URL accessible.
let path = source_path(&source)?; let path = source_path(&source)?;
let display_name = source let display_name = source
.display_name .display_name
@@ -49,14 +75,42 @@ pub(crate) fn collect_import_files(sources: Vec<ShareSource>) -> Result<Vec<Impo
collect_dir_files(&path, &display_name, &mut files)?; collect_dir_files(&path, &display_name, &mut files)?;
} else { } else {
files.push(ImportSourceFile { files.push(ImportSourceFile {
path, source: ImportSource::Path(path),
collection_name: validated_relative_string(&display_name)?,
});
}
}
SourceKind::FileDescriptor => {
#[cfg(not(unix))]
{
anyhow::bail!(
"file descriptor sources are only supported on Unix-like targets"
);
}
#[cfg(unix)]
{
if source.is_directory {
anyhow::bail!("file descriptor sources cannot represent directories yet");
}
let display_name = source
.display_name
.clone()
.and_then(non_empty)
.unwrap_or_else(|| "transfer".to_string());
// Android SAF/content URIs are not paths. Platform code opens
// the URI as a ParcelFileDescriptor, then Rust duplicates the
// borrowed fd here and streams from its owned duplicate.
files.push(ImportSourceFile {
source: ImportSource::FileDescriptor(duplicate_file_descriptor(
&source.value,
)?),
collection_name: validated_relative_string(&display_name)?, collection_name: validated_relative_string(&display_name)?,
}); });
} }
} }
SourceKind::AndroidContentUri => { SourceKind::AndroidContentUri => {
anyhow::bail!( anyhow::bail!(
"Android content URI streaming needs platform file descriptor glue before it can be imported without copying" "Android content URIs are not filesystem paths; open a ParcelFileDescriptor in Kotlin and pass its fd as SourceKind.FileDescriptor"
); );
} }
} }
@@ -68,13 +122,14 @@ pub(crate) fn collect_import_files(sources: Vec<ShareSource>) -> Result<Vec<Impo
} }
fn source_path(source: &ShareSource) -> Result<PathBuf> { fn source_path(source: &ShareSource) -> Result<PathBuf> {
if matches!(source.kind, SourceKind::IosSecurityScopedUrl) platform_path(&source.value)
&& source.value.starts_with("file://") }
{
let without_scheme = source.value.trim_start_matches("file://"); pub(crate) fn platform_path(value: &str) -> Result<PathBuf> {
if let Some(without_scheme) = value.strip_prefix("file://") {
return Ok(PathBuf::from(percent_decode_file_url_path(without_scheme)?)); return Ok(PathBuf::from(percent_decode_file_url_path(without_scheme)?));
} }
Ok(PathBuf::from(&source.value)) Ok(PathBuf::from(value))
} }
fn collect_dir_files( fn collect_dir_files(
@@ -93,7 +148,7 @@ fn collect_dir_files(
.context("failed to compute relative path")?; .context("failed to compute relative path")?;
let collection_name = path_to_string(Path::new(display_name).join(relative), true)?; let collection_name = path_to_string(Path::new(display_name).join(relative), true)?;
files.push(ImportSourceFile { files.push(ImportSourceFile {
path: entry.path().to_path_buf(), source: ImportSource::Path(entry.path().to_path_buf()),
collection_name, collection_name,
}); });
} }
@@ -121,6 +176,9 @@ pub(crate) fn read_stream_from_blocking_reader<R>(
where where
R: Read + Send + 'static, R: Read + Send + 'static,
{ {
// Keep the FFI boundary out of the data path: blocking OS reads feed a
// small bounded channel, so large files are streamed into iroh-blobs
// without copying the whole file through Kotlin memory.
let (tx, rx) = async_channel::bounded(2); let (tx, rx) = async_channel::bounded(2);
std::thread::spawn(move || { std::thread::spawn(move || {
let mut buffer = vec![0; STREAM_BUFFER_LEN]; let mut buffer = vec![0; STREAM_BUFFER_LEN];
@@ -225,3 +283,23 @@ pub(crate) fn percent_decode_file_url_path(value: &str) -> Result<String> {
} }
Ok(String::from_utf8(output)?) Ok(String::from_utf8(output)?)
} }
#[cfg(unix)]
fn duplicate_file_descriptor(value: &str) -> Result<OwnedFd> {
let fd = value
.parse::<i32>()
.context("file descriptor source value must be an integer fd")?;
if fd < 0 {
anyhow::bail!("file descriptor must be non-negative");
}
// Android's ParcelFileDescriptor remains owned by Kotlin. Rust duplicates
// it immediately so the blocking import thread can close its own handle
// without racing or invalidating the platform owner.
let duplicated = unsafe { libc::dup(fd) };
if duplicated < 0 {
return Err(io::Error::last_os_error()).context("failed to duplicate file descriptor");
}
let owned = unsafe { OwnedFd::from_raw_fd(duplicated) };
Ok(owned)
}

View File

@@ -31,8 +31,9 @@ use crate::{
}, },
error::VnidropError, error::VnidropError,
filesystem::{ filesystem::{
collect_import_files, default_collection_name, read_stream_from_blocking_reader, collect_import_files, default_collection_name, platform_path,
safe_output_path, wait_for_writer, write_stream_to_blocking_writer, TransferImport, read_stream_from_blocking_reader, safe_output_path, wait_for_writer,
write_stream_to_blocking_writer, TransferImport,
}, },
logging::init_logging, logging::init_logging,
repository::Repository, repository::Repository,
@@ -97,11 +98,9 @@ impl VnidropCore {
output_dir: String, output_dir: String,
receiver_name: Option<String>, receiver_name: Option<String>,
) -> Result<(), VnidropError> { ) -> Result<(), VnidropError> {
let output_dir = platform_path(&output_dir)?;
self.runtime self.runtime
.block_on( .block_on(self.inner.receive(ticket, output_dir, receiver_name))
self.inner
.receive(ticket, PathBuf::from(output_dir), receiver_name),
)
.map_err(Into::into) .map_err(Into::into)
} }
@@ -519,8 +518,7 @@ impl CoreInner {
.map(|file| { .map(|file| {
let core = self.clone(); let core = self.clone();
async move { async move {
let reader = File::open(&file.path) let reader = file.source.open()?;
.with_context(|| format!("failed to open {}", file.path.display()))?;
let stream = read_stream_from_blocking_reader(reader); let stream = read_stream_from_blocking_reader(reader);
let import = core.store.add_stream(stream).await; let import = core.store.add_stream(stream).await;
let (tag, size) = core let (tag, size) = core

View File

@@ -1,14 +1,19 @@
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::{path::Path, sync::Arc}; #[cfg(unix)]
use std::os::fd::AsRawFd;
use std::{io::Read, path::Path, sync::Arc};
use iroh::SecretKey; use iroh::SecretKey;
use iroh_blobs::{ticket::BlobTicket, BlobFormat, Hash}; use iroh_blobs::{ticket::BlobTicket, BlobFormat, Hash};
use crate::{ use crate::{
access_policy::{AccessDecision, AccessPolicy}, access_policy::{AccessDecision, AccessPolicy},
api::{CoreEvent, CoreEventSink, TransferMetadata}, api::{CoreEvent, CoreEventSink, ShareSource, SourceKind, TransferMetadata},
filesystem::{path_to_string, percent_decode_file_url_path, validated_relative_string}, filesystem::{
collect_import_files, path_to_string, percent_decode_file_url_path,
validated_relative_string,
},
repository::Repository, repository::Repository,
runtime::VnidropCore, runtime::VnidropCore,
secret::load_or_create_secret, secret::load_or_create_secret,
@@ -75,6 +80,27 @@ mod tests {
); );
} }
#[cfg(unix)]
#[test]
fn file_descriptor_source_duplicates_and_streams() {
let mut temp = tempfile::tempfile().unwrap();
std::io::Write::write_all(&mut temp, b"fd-backed import").unwrap();
std::io::Seek::rewind(&mut temp).unwrap();
let files = collect_import_files(vec![ShareSource {
kind: SourceKind::FileDescriptor,
value: temp.as_raw_fd().to_string(),
display_name: Some("from-fd.txt".to_string()),
is_directory: false,
}])
.unwrap();
let mut imported = files.into_iter().next().unwrap().source.open().unwrap();
let mut content = String::new();
imported.read_to_string(&mut content).unwrap();
assert_eq!(content, "fd-backed import");
}
#[test] #[test]
fn can_initialize_core() { fn can_initialize_core() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();

View File

@@ -0,0 +1,9 @@
package com.vnidrop.app.core
import uniffi.vnidrop.SourceKind
internal actual suspend fun <T> withPlatformPathAccess(
kind: SourceKind,
value: String,
block: suspend () -> T,
): T = block()

View File

@@ -50,8 +50,7 @@ class CoreRepository(
} }
suspend fun sharePath(path: String, transferName: String, senderName: String) = runCore { suspend fun sharePath(path: String, transferName: String, senderName: String) = runCore {
val active = requireCore() shareSources(
val result = active.shareFiles(
sources = listOf( sources = listOf(
ShareSource( ShareSource(
kind = SourceKind.PATH, kind = SourceKind.PATH,
@@ -60,14 +59,54 @@ class CoreRepository(
isDirectory = false, isDirectory = false,
), ),
), ),
metadata = ShareMetadataInput( transferName = transferName,
transferId = nextTransferId(), senderName = senderName,
transferName = transferName.ifBlank { null }, )
senderName = senderName.ifBlank { null }, }
),
suspend fun shareFileDescriptor(
fd: Int,
displayName: String,
transferName: String,
senderName: String,
) = runCore {
// The fd is borrowed from platform code. Rust duplicates it before
// starting the import, so Android may close the ParcelFileDescriptor
// once this suspend call returns.
shareSources(
sources = listOf(
ShareSource(
kind = SourceKind.FILE_DESCRIPTOR,
value = fd.toString(),
displayName = displayName.ifBlank { "transfer" },
isDirectory = false,
),
),
transferName = transferName,
senderName = senderName,
)
}
suspend fun shareSecurityScopedFileUrl(
fileUrl: String,
displayName: String,
transferName: String,
senderName: String,
) = runCore {
// The iOS actual for withPlatformPathAccess starts and stops the
// security-scoped URL lease around this entire shareFiles call.
shareSources(
sources = listOf(
ShareSource(
kind = SourceKind.IOS_SECURITY_SCOPED_URL,
value = fileUrl,
displayName = displayName.ifBlank { fileUrl.substringAfterLast('/').ifBlank { "transfer" } },
isDirectory = false,
),
),
transferName = transferName,
senderName = senderName,
) )
_state.update { it.copy(lastShare = result, error = null) }
refreshStatus()
} }
suspend fun inspectTicket(ticket: String) = runCore { suspend fun inspectTicket(ticket: String) = runCore {
@@ -80,6 +119,17 @@ class CoreRepository(
refreshStatus() refreshStatus()
} }
suspend fun receiveIntoSecurityScopedDirectory(
ticket: String,
outputDirectoryUrl: String,
receiverName: String,
) = runCore {
withPlatformPathAccess(SourceKind.IOS_SECURITY_SCOPED_URL, outputDirectoryUrl) {
requireCore().receive(ticket, outputDirectoryUrl, receiverName.ifBlank { null })
}
refreshStatus()
}
suspend fun cancel(transferId: ULong) = runCore { suspend fun cancel(transferId: ULong) = runCore {
requireCore().cancelTransfer(transferId) requireCore().cancelTransfer(transferId)
refreshStatus() refreshStatus()
@@ -98,6 +148,39 @@ class CoreRepository(
private fun requireCore(): VnidropCore = private fun requireCore(): VnidropCore =
core ?: error("Initialize the core first.") core ?: error("Initialize the core first.")
private suspend fun shareSources(
sources: List<ShareSource>,
transferName: String,
senderName: String,
) {
withPlatformPathAccess(sources) {
val result = requireCore().shareFiles(
sources = sources,
metadata = ShareMetadataInput(
transferId = nextTransferId(),
transferName = transferName.ifBlank { null },
senderName = senderName.ifBlank { null },
),
)
_state.update { it.copy(lastShare = result, error = null) }
}
refreshStatus()
}
private suspend fun <T> withPlatformPathAccess(
sources: List<ShareSource>,
index: Int = 0,
block: suspend () -> T,
): T {
if (index >= sources.size) {
return block()
}
val source = sources[index]
return withPlatformPathAccess(source.kind, source.value) {
withPlatformPathAccess(sources, index + 1, block)
}
}
private fun refreshStatus() { private fun refreshStatus() {
val status = core?.status() val status = core?.status()
_state.update { _state.update {

View File

@@ -0,0 +1,13 @@
package com.vnidrop.app.core
import uniffi.vnidrop.SourceKind
// Platform file handles have different lifetime rules. Desktop paths need no
// extra work, Android fd sources are duplicated immediately by Rust, and iOS
// security-scoped URLs must remain leased while Rust performs the blocking
// import/export call.
internal expect suspend fun <T> withPlatformPathAccess(
kind: SourceKind,
value: String,
block: suspend () -> T,
): T

View File

@@ -0,0 +1,24 @@
package com.vnidrop.app.core
import platform.Foundation.NSURL
import uniffi.vnidrop.SourceKind
internal actual suspend fun <T> withPlatformPathAccess(
kind: SourceKind,
value: String,
block: suspend () -> T,
): T {
if (kind != SourceKind.IOS_SECURITY_SCOPED_URL) {
return block()
}
val url = NSURL.URLWithString(value) ?: NSURL.fileURLWithPath(value)
val didStartAccess = url.startAccessingSecurityScopedResource()
return try {
block()
} finally {
if (didStartAccess) {
url.stopAccessingSecurityScopedResource()
}
}
}

View File

@@ -0,0 +1,9 @@
package com.vnidrop.app.core
import uniffi.vnidrop.SourceKind
internal actual suspend fun <T> withPlatformPathAccess(
kind: SourceKind,
value: String,
block: suspend () -> T,
): T = block()