mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
Add mobile file access sources
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -5025,6 +5025,7 @@ dependencies = [
|
|||||||
"iroh",
|
"iroh",
|
||||||
"iroh-blobs",
|
"iroh-blobs",
|
||||||
"irpc",
|
"irpc",
|
||||||
|
"libc",
|
||||||
"n0-future",
|
"n0-future",
|
||||||
"num_cpus",
|
"num_cpus",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user