feat(fs): access security scoped resources on iOS (#3185)

Co-authored-by: FabianLars <30730186+FabianLars@users.noreply.github.com>
This commit is contained in:
Lucas Fernandes Nogueira
2026-03-04 09:59:03 -03:00
committed by GitHub
parent 36d3d19247
commit f5f68063e4
15 changed files with 893 additions and 94 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"fs": minor
"fs-js": minor
---
Enable access for security-scoped resources on iOS by automatically calling `NSURL::startAccessingSecurityScopedResource` on resource access and adding the `stopAccessingSecurityScopedResource` API.
Generated
+2
View File
@@ -6710,8 +6710,10 @@ dependencies = [
"anyhow",
"dunce",
"glob",
"log",
"notify",
"notify-debouncer-full",
"objc2-foundation 0.3.0",
"percent-encoding",
"schemars",
"serde",
+4
View File
@@ -30,6 +30,7 @@ serde_repr = "0.1"
tauri = { workspace = true }
thiserror = { workspace = true }
url = { workspace = true }
log = { workspace = true }
anyhow = "1"
glob = { workspace = true }
# TODO: Remove `serialization-compat-6` in v3
@@ -41,5 +42,8 @@ notify-debouncer-full = { version = "0.6", optional = true }
dunce = { workspace = true }
percent-encoding = "2"
[target.'cfg(target_os = "ios")'.dependencies]
objc2-foundation = { version = "0.3", features = ["NSURL", "NSString"] }
[features]
watch = ["notify", "notify-debouncer-full"]
File diff suppressed because one or more lines are too long
+2
View File
@@ -104,6 +104,8 @@ const COMMANDS: &[(&str, &[&str])] = &[
// TODO: Remove this in v3
("unwatch", &[]),
("size", &[]),
("start_accessing_security_scoped_resource", &[]),
("stop_accessing_security_scoped_resource", &[]),
];
fn main() {
+89 -1
View File
@@ -5,6 +5,19 @@
/**
* Access the file system.
*
* ## iOS security-scoped resources
*
* On iOS, the `fs` plugin automatically manages access to security-scoped resources when a file URL is accessed.
* This is required for files outside the app's sandbox (e.g., from file picker).
*
* @example
* ```typescript
* import { open } from '@tauri-apps/plugin-fs';
*
* const file = await open('file:///path/to/file.txt');
* await file.close();
* ```
*
* ## Security
*
* This module prevents path traversal, not allowing parent directory accessors to be used
@@ -1353,6 +1366,79 @@ async function size(path: string | URL): Promise<number> {
})
}
/**
* Starts accessing a security-scoped resource for the given file URL.
* This should be called when you're accessing a file that was opened
* using a security-scoped URL (e.g., from a file picker).
*
* Note that accessing security-scoped resources is automatically managed by the plugin on iOS, so you don't need to call this function
* unless you want to manage the scope manually.
*
* You must call {@linkcode stopAccessingSecurityScopedResource} when you're done accessing the resource.
*
* #### Platform-specific
*
* - **iOS:** Starts accessing the security-scoped resource.
* - **Other platforms:** does nothing.
*
* @example
* ```typescript
* import { startAccessingSecurityScopedResource } from '@tauri-apps/plugin-fs';
*
* const filePath = 'file:///path/to/file.txt';
* await startAccessingSecurityScopedResource(filePath);
* // ... use the resource ...
* ```
*
* @since 2.5.0
*/
async function startAccessingSecurityScopedResource(
path: string | URL
): Promise<void> {
if (path instanceof URL && path.protocol !== 'file:') {
throw new TypeError('Must be a file URL.')
}
await invoke('plugin:fs|start_accessing_security_scoped_resource', {
path: path instanceof URL ? path.toString() : path
})
}
/**
* Stops accessing a security-scoped resource for the given file URL.
* This should be called when you're done accessing a file that was opened
* using a security-scoped URL (e.g., from a file picker) when using manual tracking via {@linkcode startAccessingSecurityScopedResource}.
*
* #### Platform-specific
*
* - **iOS:** Stops accessing the security-scoped resource.
* - **Other platforms:** does nothing.
*
* @example
* ```typescript
* import { stopAccessingSecurityScopedResource } from '@tauri-apps/plugin-fs';
*
* const filePath = 'file:///path/to/file.txt';
* await startAccessingSecurityScopedResource(filePath);
* // ... use the resource ...
* // when you're done with the resource:
* await stopAccessingSecurityScopedResource(filePath);
* ```
*
* @since 2.5.0
*/
async function stopAccessingSecurityScopedResource(
path: string | URL
): Promise<void> {
if (path instanceof URL && path.protocol !== 'file:') {
throw new TypeError('Must be a file URL.')
}
await invoke('plugin:fs|stop_accessing_security_scoped_resource', {
path: path instanceof URL ? path.toString() : path
})
}
export type {
CreateOptions,
OpenOptions,
@@ -1401,5 +1487,7 @@ export {
exists,
watch,
watchImmediate,
size
size,
startAccessingSecurityScopedResource,
stopAccessingSecurityScopedResource
}
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-start-accessing-security-scoped-resource"
description = "Enables the start_accessing_security_scoped_resource command without any pre-configured scope."
commands.allow = ["start_accessing_security_scoped_resource"]
[[permission]]
identifier = "deny-start-accessing-security-scoped-resource"
description = "Denies the start_accessing_security_scoped_resource command without any pre-configured scope."
commands.deny = ["start_accessing_security_scoped_resource"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-stop-accessing-security-scoped-resource"
description = "Enables the stop_accessing_security_scoped_resource command without any pre-configured scope."
commands.allow = ["stop_accessing_security_scoped_resource"]
[[permission]]
identifier = "deny-stop-accessing-security-scoped-resource"
description = "Denies the stop_accessing_security_scoped_resource command without any pre-configured scope."
commands.deny = ["stop_accessing_security_scoped_resource"]
@@ -3435,6 +3435,32 @@ Denies the size command without any pre-configured scope.
<tr>
<td>
`fs:allow-start-accessing-security-scoped-resource`
</td>
<td>
Enables the start_accessing_security_scoped_resource command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`fs:deny-start-accessing-security-scoped-resource`
</td>
<td>
Denies the start_accessing_security_scoped_resource command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`fs:allow-stat`
</td>
@@ -3461,6 +3487,32 @@ Denies the stat command without any pre-configured scope.
<tr>
<td>
`fs:allow-stop-accessing-security-scoped-resource`
</td>
<td>
Enables the stop_accessing_security_scoped_resource command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`fs:deny-stop-accessing-security-scoped-resource`
</td>
<td>
Denies the stop_accessing_security_scoped_resource command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`fs:allow-truncate`
</td>
@@ -1860,6 +1860,18 @@
"const": "deny-size",
"markdownDescription": "Denies the size command without any pre-configured scope."
},
{
"description": "Enables the start_accessing_security_scoped_resource command without any pre-configured scope.",
"type": "string",
"const": "allow-start-accessing-security-scoped-resource",
"markdownDescription": "Enables the start_accessing_security_scoped_resource command without any pre-configured scope."
},
{
"description": "Denies the start_accessing_security_scoped_resource command without any pre-configured scope.",
"type": "string",
"const": "deny-start-accessing-security-scoped-resource",
"markdownDescription": "Denies the start_accessing_security_scoped_resource command without any pre-configured scope."
},
{
"description": "Enables the stat command without any pre-configured scope.",
"type": "string",
@@ -1872,6 +1884,18 @@
"const": "deny-stat",
"markdownDescription": "Denies the stat command without any pre-configured scope."
},
{
"description": "Enables the stop_accessing_security_scoped_resource command without any pre-configured scope.",
"type": "string",
"const": "allow-stop-accessing-security-scoped-resource",
"markdownDescription": "Enables the stop_accessing_security_scoped_resource command without any pre-configured scope."
},
{
"description": "Denies the stop_accessing_security_scoped_resource command without any pre-configured scope.",
"type": "string",
"const": "deny-stop-accessing-security-scoped-resource",
"markdownDescription": "Denies the stop_accessing_security_scoped_resource command without any pre-configured scope."
},
{
"description": "Enables the truncate command without any pre-configured scope.",
"type": "string",
@@ -3,37 +3,31 @@
// SPDX-License-Identifier: MIT
use serde::de::DeserializeOwned;
use tauri::{
plugin::{PluginApi, PluginHandle},
AppHandle, Runtime,
};
use tauri::{plugin::PluginApi, AppHandle, Runtime};
use crate::{models::*, FilePath, OpenOptions};
#[cfg(target_os = "android")]
const PLUGIN_IDENTIFIER: &str = "com.plugin.fs";
#[cfg(target_os = "ios")]
tauri::ios_plugin_binding!(init_plugin_fs);
pub struct Fs<R: Runtime>(tauri::plugin::PluginHandle<R>);
// initializes the Kotlin or Swift plugin classes
pub fn init<R: Runtime, C: DeserializeOwned>(
_app: &AppHandle<R>,
api: PluginApi<R, C>,
) -> crate::Result<Fs<R>> {
#[cfg(target_os = "android")]
let handle = api
.register_android_plugin(PLUGIN_IDENTIFIER, "FsPlugin")
.unwrap();
#[cfg(target_os = "ios")]
let handle = api.register_ios_plugin(init_plugin_android - intent - send)?;
Ok(Fs(handle))
}
/// Access to the android-intent-send APIs.
pub struct Fs<R: Runtime>(PluginHandle<R>);
impl<R: Runtime> Fs<R> {
/// Open a file.
///
/// # Platform-specific
///
/// - **iOS**: This method will automatically start accessing a security-scoped resource if the path is a file URL.
/// You must call `stop_accessing_security_scoped_resource` when you're done accessing the file.
pub fn open<P: Into<FilePath>>(
&self,
path: P,
@@ -68,29 +62,25 @@ impl<R: Runtime> Fs<R> {
}
}
#[cfg(target_os = "android")]
fn resolve_content_uri(
&self,
uri: impl Into<String>,
mode: impl Into<String>,
) -> crate::Result<std::fs::File> {
#[cfg(target_os = "android")]
{
let result = self.0.run_mobile_plugin::<GetFileDescriptorResponse>(
"getFileDescriptor",
GetFileDescriptorPayload {
uri: uri.into(),
mode: mode.into(),
},
)?;
if let Some(fd) = result.fd {
Ok(unsafe {
use std::os::fd::FromRawFd;
std::fs::File::from_raw_fd(fd)
})
} else {
unimplemented!()
}
let result = self.0.run_mobile_plugin::<GetFileDescriptorResponse>(
"getFileDescriptor",
GetFileDescriptorPayload {
uri: uri.into(),
mode: mode.into(),
},
)?;
if let Some(fd) = result.fd {
Ok(unsafe {
use std::os::fd::FromRawFd;
std::fs::File::from_raw_fd(fd)
})
} else {
unimplemented!()
}
}
}
+447 -52
View File
@@ -16,6 +16,7 @@ use std::{
borrow::Cow,
fs::File,
io::{BufRead, BufReader, Read, Write},
ops::{Deref, DerefMut},
path::{Path, PathBuf},
str::FromStr,
sync::Mutex,
@@ -70,6 +71,209 @@ impl Serialize for CommandError {
pub type CommandResult<T> = std::result::Result<T, CommandError>;
/// Represents either a plain PathBuf or a PathHandle that manages security-scoped resources.
pub enum PathKind<R: Runtime> {
/// A plain path that doesn't manage security-scoped resources.
#[allow(dead_code)] // only used on mobile
Path(PathBuf),
/// A path handle that manages security-scoped resources and will clean them up on drop.
Handle(PathHandle<R>),
}
impl<R: Runtime> PathKind<R> {
/// Get a reference to the underlying path.
pub fn as_path(&self) -> &Path {
match self {
PathKind::Path(p) => p.as_ref(),
PathKind::Handle(h) => h.as_ref(),
}
}
/// Get a reference to the underlying PathBuf.
pub fn as_path_buf(&self) -> &PathBuf {
match self {
PathKind::Path(p) => p,
PathKind::Handle(h) => h,
}
}
}
impl<R: Runtime> AsRef<Path> for PathKind<R> {
fn as_ref(&self) -> &Path {
self.as_path()
}
}
impl<R: Runtime> AsRef<PathBuf> for PathKind<R> {
fn as_ref(&self) -> &PathBuf {
self.as_path_buf()
}
}
/// A file handle that automatically stops accessing security-scoped resources on iOS when dropped.
pub struct FileHandle<R: Runtime> {
file: File,
path: PathKind<R>,
#[allow(dead_code)] // Used in Drop implementation
path_: SafeFilePath,
#[allow(dead_code)] // Used in Drop implementation
app_handle: tauri::AppHandle<R>,
}
impl<R: Runtime> FileHandle<R> {
fn new(
file: File,
path: PathKind<R>,
path_: SafeFilePath,
app_handle: tauri::AppHandle<R>,
) -> Self {
Self {
file,
path,
path_,
app_handle,
}
}
/// Get the resolved path.
pub fn path(&self) -> &Path {
self.path.as_path()
}
}
impl<R: Runtime> Deref for FileHandle<R> {
type Target = File;
fn deref(&self) -> &Self::Target {
&self.file
}
}
impl<R: Runtime> DerefMut for FileHandle<R> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.file
}
}
impl<R: Runtime> Drop for FileHandle<R> {
fn drop(&mut self) {
#[cfg(target_os = "ios")]
{
// Only clean up if we have a plain PathBuf, not a PathHandle
// PathHandle will handle its own cleanup when it's dropped
if let PathKind::Path(_) = &self.path {
use crate::{FilePath, FsExt};
// Convert SafeFilePath to FilePath
let file_path: FilePath = match &self.path_ {
SafeFilePath::Url(url) => FilePath::Url(url.clone()),
SafeFilePath::Path(safe_path) => FilePath::Path(safe_path.as_ref().to_owned()),
};
// Only clean up if we're tracking this resource
// If start_accessing_security_scoped_resource was used, it won't be in our tracking
// and we shouldn't interfere
if let FilePath::Url(url) = file_path {
if url.scheme() == "file" {
let security_scoped_resources =
self.app_handle.state::<crate::SecurityScopedResources>();
// Only clean up if it's not tracked manually
if !security_scoped_resources.is_tracked_manually(url.as_str()) {
log::debug!("Stopping accessing security-scoped resource for URL: {url} on drop");
let _ = self
.app_handle
.fs()
.stop_accessing_security_scoped_resource(FilePath::Url(
url.clone(),
));
security_scoped_resources.remove(url.as_str());
} else {
log::debug!("Not cleaning up security-scoped resource for URL: {url} on drop (manually tracked via start_accessing_security_scoped_resource)");
}
}
}
}
}
}
}
/// A path handle that automatically stops accessing security-scoped resources on iOS when dropped.
pub struct PathHandle<R: Runtime> {
path: PathBuf,
#[allow(dead_code)] // Used in Drop implementation
path_: SafeFilePath,
#[allow(dead_code)] // Used in Drop implementation
app_handle: tauri::AppHandle<R>,
}
impl<R: Runtime> PathHandle<R> {
fn new(path: PathBuf, path_: SafeFilePath, app_handle: tauri::AppHandle<R>) -> Self {
Self {
path,
path_,
app_handle,
}
}
}
impl<R: Runtime> Deref for PathHandle<R> {
type Target = PathBuf;
fn deref(&self) -> &Self::Target {
&self.path
}
}
impl<R: Runtime> AsRef<Path> for PathHandle<R> {
fn as_ref(&self) -> &Path {
self.path.as_ref()
}
}
impl<R: Runtime> AsRef<PathBuf> for PathHandle<R> {
fn as_ref(&self) -> &PathBuf {
&self.path
}
}
impl<R: Runtime> Drop for PathHandle<R> {
fn drop(&mut self) {
#[cfg(target_os = "ios")]
{
use crate::{FilePath, FsExt};
// Convert SafeFilePath to FilePath
let file_path: FilePath = match &self.path_ {
SafeFilePath::Url(url) => FilePath::Url(url.clone()),
SafeFilePath::Path(safe_path) => FilePath::Path(safe_path.as_ref().to_owned()),
};
// Only clean up if we're tracking this resource (i.e., resolve_path started it)
// If start_accessing_security_scoped_resource was used, it won't be in our tracking
// and we shouldn't interfere
if let FilePath::Url(url) = file_path {
if url.scheme() == "file" {
let security_scoped_resources =
self.app_handle.state::<crate::SecurityScopedResources>();
// Only clean up if it's not tracked manually
if !security_scoped_resources.is_tracked_manually(url.as_str()) {
log::debug!(
"Stopping accessing security-scoped resource for URL: {url} on drop"
);
let _ = self
.app_handle
.fs()
.stop_accessing_security_scoped_resource(FilePath::Url(url.clone()));
security_scoped_resources.remove(url.as_str());
} else {
log::debug!("Not cleaning up security-scoped resource for URL: {url} on drop (manually tracked via start_accessing_security_scoped_resource)");
}
}
}
}
}
}
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BaseOptions {
@@ -84,7 +288,8 @@ pub fn create<R: Runtime>(
path: SafeFilePath,
options: Option<BaseOptions>,
) -> CommandResult<ResourceId> {
let resolved_path = resolve_path(
let path_ = path.clone();
let resolved_path_handle = resolve_path(
"create",
&webview,
&global_scope,
@@ -92,13 +297,22 @@ pub fn create<R: Runtime>(
path,
options.and_then(|o| o.base_dir),
)?;
let file = File::create(&resolved_path).map_err(|e| {
let file = File::create(&*resolved_path_handle).map_err(|e| {
format!(
"failed to create file at path: {} with error: {e}",
resolved_path.display()
resolved_path_handle.display()
)
})?;
let rid = webview.resources_table().add(StdFileResource::new(file));
let app_handle = webview.app_handle().clone();
let file_handle = FileHandle::new(
file,
PathKind::Handle(resolved_path_handle),
path_,
app_handle,
);
let rid = webview
.resources_table()
.add(StdFileResource::new(file_handle));
Ok(rid)
}
@@ -119,7 +333,7 @@ pub fn open<R: Runtime>(
path: SafeFilePath,
options: Option<OpenOptions>,
) -> CommandResult<ResourceId> {
let (file, _path) = resolve_file(
let file_handle = resolve_file(
"open",
&webview,
&global_scope,
@@ -147,7 +361,9 @@ pub fn open<R: Runtime>(
},
)?;
let rid = webview.resources_table().add(StdFileResource::new(file));
let rid = webview
.resources_table()
.add(StdFileResource::new(file_handle));
Ok(rid)
}
@@ -308,8 +524,8 @@ pub async fn read<R: Runtime>(
len: usize,
) -> CommandResult<tauri::ipc::Response> {
let mut data = vec![0; len];
let file = webview.resources_table().get::<StdFileResource>(rid)?;
let nread = StdFileResource::with_lock(&file, |mut file| file.read(&mut data))
let file: std::sync::Arc<StdFileResource<R>> = webview.resources_table().get(rid)?;
let nread = StdFileResource::with_lock(&file, |file| file.read(&mut data))
.map_err(|e| format!("faied to read bytes from file with error: {e}"))?;
// This is an optimization to include the number of read bytes (as bigendian bytes)
@@ -345,7 +561,7 @@ async fn read_file_inner<R: Runtime>(
path: SafeFilePath,
options: Option<BaseOptions>,
) -> CommandResult<tauri::ipc::Response> {
let (mut file, path) = resolve_file(
let mut file_handle = resolve_file(
permission,
&webview,
&global_scope,
@@ -364,10 +580,10 @@ async fn read_file_inner<R: Runtime>(
let mut contents = Vec::new();
file.read_to_end(&mut contents).map_err(|e| {
file_handle.read_to_end(&mut contents).map_err(|e| {
format!(
"failed to read file as text at path: {} with error: {e}",
path.display()
file_handle.path().display()
)
})?;
@@ -638,8 +854,8 @@ pub async fn seek<R: Runtime>(
whence: SeekMode,
) -> CommandResult<u64> {
use std::io::{Seek, SeekFrom};
let file = webview.resources_table().get::<StdFileResource>(rid)?;
StdFileResource::with_lock(&file, |mut file| {
let file: std::sync::Arc<StdFileResource<R>> = webview.resources_table().get(rid)?;
StdFileResource::with_lock(&file, |file| {
file.seek(match whence {
SeekMode::Start => SeekFrom::Start(offset as u64),
SeekMode::Current => SeekFrom::Current(offset),
@@ -662,7 +878,7 @@ fn get_metadata<R: Runtime, F: FnOnce(&PathBuf) -> std::io::Result<std::fs::Meta
) -> CommandResult<std::fs::Metadata> {
match path {
SafeFilePath::Url(url) => {
let (file, path) = resolve_file(
let file_handle = resolve_file(
permission,
webview,
global_scope,
@@ -676,10 +892,10 @@ fn get_metadata<R: Runtime, F: FnOnce(&PathBuf) -> std::io::Result<std::fs::Meta
},
},
)?;
file.metadata().map_err(|e| {
file_handle.metadata().map_err(|e| {
format!(
"failed to get metadata of path: {} with error: {e}",
path.display()
file_handle.path().display()
)
.into()
})
@@ -786,7 +1002,7 @@ pub fn lstat<R: Runtime>(
#[tauri::command]
pub fn fstat<R: Runtime>(webview: Webview<R>, rid: ResourceId) -> CommandResult<FileInfo> {
let file = webview.resources_table().get::<StdFileResource>(rid)?;
let file: std::sync::Arc<StdFileResource<R>> = webview.resources_table().get(rid)?;
let metadata = StdFileResource::with_lock(&file, |file| file.metadata())
.map_err(|e| format!("failed to get metadata of file with error: {e}"))?;
Ok(get_stat(metadata))
@@ -834,7 +1050,7 @@ pub async fn ftruncate<R: Runtime>(
rid: ResourceId,
len: Option<u64>,
) -> CommandResult<()> {
let file = webview.resources_table().get::<StdFileResource>(rid)?;
let file: std::sync::Arc<StdFileResource<R>> = webview.resources_table().get(rid)?;
StdFileResource::with_lock(&file, |file| file.set_len(len.unwrap_or(0)))
.map_err(|e| format!("failed to truncate file with error: {e}"))
.map_err(Into::into)
@@ -846,8 +1062,8 @@ pub async fn write<R: Runtime>(
rid: ResourceId,
data: Vec<u8>,
) -> CommandResult<usize> {
let file = webview.resources_table().get::<StdFileResource>(rid)?;
StdFileResource::with_lock(&file, |mut file| file.write(&data))
let file: std::sync::Arc<StdFileResource<R>> = webview.resources_table().get(rid)?;
StdFileResource::with_lock(&file, |file| file.write(&data))
.map_err(|e| format!("failed to write bytes to file with error: {e}"))
.map_err(Into::into)
}
@@ -895,7 +1111,7 @@ async fn write_file_inner<R: Runtime>(
.and_then(|p| p.to_str().ok())
.and_then(|opts| serde_json::from_str(opts).ok());
let (mut file, path) = resolve_file(
let mut file_handle = resolve_file(
permission,
&webview,
&global_scope,
@@ -942,11 +1158,12 @@ async fn write_file_inner<R: Runtime>(
_ => return Err(anyhow::anyhow!("unexpected invoke body").into()),
};
file.write_all(&data)
file_handle
.write_all(&data)
.map_err(|e| {
format!(
"failed to write bytes to file at path: {} with error: {e}",
path.display()
file_handle.path().display()
)
})
.map_err(Into::into)
@@ -1032,6 +1249,130 @@ pub async fn size<R: Runtime>(
}
}
#[tauri::command]
pub fn start_accessing_security_scoped_resource<R: Runtime>(
webview: Webview<R>,
path: SafeFilePath,
) -> CommandResult<()> {
#[cfg(target_os = "ios")]
{
use crate::FilePath;
// Convert SafeFilePath to FilePath
let file_path: FilePath = match &path {
SafeFilePath::Url(url) => FilePath::Url(url.clone()),
SafeFilePath::Path(safe_path) => FilePath::Path(safe_path.as_ref().to_owned()),
};
// Only handle file URLs
if let FilePath::Url(url) = &file_path {
if url.scheme() == "file" {
use objc2_foundation::{NSString, NSURL};
let url_nsstring = NSString::from_str(url.as_str());
let ns_url = unsafe { NSURL::URLWithString(&url_nsstring) };
if let Some(ns_url) = ns_url {
// Check if already active
let security_scoped_resources =
webview.state::<crate::SecurityScopedResources>();
if security_scoped_resources.is_tracked_manually(url.as_str()) {
log::debug!(
"Security-scoped resource already active for URL: {}",
url.as_str()
);
return Ok(());
}
// Start accessing the security-scoped resource
unsafe {
let success = ns_url.startAccessingSecurityScopedResource();
if success {
log::debug!(
"Started accessing security-scoped resource for URL: {}",
url.as_str()
);
security_scoped_resources.track_manually(url.as_str().to_string());
} else {
log::warn!(
"Failed to start accessing security-scoped resource for URL: {}",
url.as_str()
);
return Err(CommandError::from(format!(
"Failed to start accessing security-scoped resource for URL: {}",
url.as_str()
)));
}
}
} else {
return Err(CommandError::from(format!(
"Failed to create NSURL from URL: {}",
url.as_str()
)));
}
}
}
Ok(())
}
#[cfg(not(target_os = "ios"))]
{
// No-op on non-iOS platforms
let _ = webview;
let _ = path;
Ok(())
}
}
#[tauri::command]
pub fn stop_accessing_security_scoped_resource<R: Runtime>(
webview: Webview<R>,
path: SafeFilePath,
) -> CommandResult<()> {
#[cfg(target_os = "ios")]
{
use crate::{FilePath, FsExt};
// Convert SafeFilePath to FilePath
let file_path: FilePath = match &path {
SafeFilePath::Url(url) => FilePath::Url(url.clone()),
SafeFilePath::Path(safe_path) => FilePath::Path(safe_path.as_ref().to_owned()),
};
// Only handle file URLs
if let FilePath::Url(url) = file_path {
if url.scheme() == "file" {
let security_scoped_resources = webview.state::<crate::SecurityScopedResources>();
// Check if it's tracked
if !security_scoped_resources.is_tracked_manually(url.as_str()) {
log::debug!(
"Security-scoped resource not tracked as active for URL: {}",
url.as_str()
);
return Ok(());
}
// Stop accessing the security-scoped resource
webview
.fs()
.stop_accessing_security_scoped_resource(FilePath::Url(url.clone()))?;
// Remove from tracking
security_scoped_resources.remove(url.as_str());
log::debug!(
"Stopped accessing security-scoped resource for URL: {}",
url.as_str()
);
}
}
Ok(())
}
#[cfg(not(target_os = "ios"))]
{
// No-op on non-iOS platforms
let _ = webview;
let _ = path;
Ok(())
}
}
fn get_dir_size(path: &PathBuf) -> CommandResult<u64> {
let mut size = 0;
@@ -1049,7 +1390,7 @@ fn get_dir_size(path: &PathBuf) -> CommandResult<u64> {
Ok(size)
}
#[cfg(not(target_os = "android"))]
#[cfg(desktop)]
pub fn resolve_file<R: Runtime>(
permission: &str,
webview: &Webview<R>,
@@ -1057,7 +1398,7 @@ pub fn resolve_file<R: Runtime>(
command_scope: &CommandScope<Entry>,
path: SafeFilePath,
open_options: OpenOptions,
) -> CommandResult<(File, PathBuf)> {
) -> CommandResult<FileHandle<R>> {
resolve_file_in_fs(
permission,
webview,
@@ -1075,8 +1416,9 @@ fn resolve_file_in_fs<R: Runtime>(
command_scope: &CommandScope<Entry>,
path: SafeFilePath,
open_options: OpenOptions,
) -> CommandResult<(File, PathBuf)> {
let path = resolve_path(
) -> CommandResult<FileHandle<R>> {
let path_ = path.clone();
let resolved_path_handle = resolve_path(
permission,
webview,
global_scope,
@@ -1086,17 +1428,24 @@ fn resolve_file_in_fs<R: Runtime>(
)?;
let file = std::fs::OpenOptions::from(open_options.options)
.open(&path)
.open(&*resolved_path_handle)
.map_err(|e| {
format!(
"failed to open file at path: {} with error: {e}",
path.display()
resolved_path_handle.display()
)
})?;
Ok((file, path))
let app_handle = webview.app_handle().clone();
Ok(FileHandle::new(
file,
PathKind::Handle(resolved_path_handle),
path_,
app_handle,
))
}
#[cfg(target_os = "android")]
#[cfg(mobile)]
pub fn resolve_file<R: Runtime>(
permission: &str,
webview: &Webview<R>,
@@ -1104,16 +1453,23 @@ pub fn resolve_file<R: Runtime>(
command_scope: &CommandScope<Entry>,
path: SafeFilePath,
open_options: OpenOptions,
) -> CommandResult<(File, PathBuf)> {
) -> CommandResult<FileHandle<R>> {
use crate::FsExt;
let path_ = path.clone();
match path {
SafeFilePath::Url(url) => {
let path = url.as_str().into();
let resolved_path = url.as_str().into();
let file = webview
.fs()
.open(SafeFilePath::Url(url), open_options.options)?;
Ok((file, path))
.open(SafeFilePath::Url(url.clone()), open_options.options)?;
let app_handle = webview.app_handle().clone();
Ok(FileHandle::new(
file,
PathKind::Path(resolved_path),
path_,
app_handle,
))
}
SafeFilePath::Path(path) => resolve_file_in_fs(
permission,
@@ -1133,9 +1489,47 @@ pub fn resolve_path<R: Runtime>(
command_scope: &CommandScope<Entry>,
path: SafeFilePath,
base_dir: Option<BaseDirectory>,
) -> CommandResult<PathBuf> {
) -> CommandResult<PathHandle<R>> {
let path_ = path.clone();
// On iOS, start accessing security-scoped resource if the path is a file URL
// Only if it hasn't been started already via start_accessing_security_scoped_resource
#[cfg(target_os = "ios")]
{
if let SafeFilePath::Url(url) = &path {
if url.scheme() == "file" {
use objc2_foundation::{NSString, NSURL};
let security_scoped_resources = webview.state::<crate::SecurityScopedResources>();
// Check if already active (started via start_accessing_security_scoped_resource)
if !security_scoped_resources.is_tracked_manually(url.as_str()) {
let url_nsstring = NSString::from_str(url.as_str());
let ns_url = unsafe { NSURL::URLWithString(&url_nsstring) };
if let Some(ns_url) = ns_url {
// Start accessing the security-scoped resource
// This is required for files outside the app's sandbox (e.g., from file picker)
unsafe {
let success = ns_url.startAccessingSecurityScopedResource();
if success {
log::debug!("Started accessing security-scoped resource for URL: {} (via resolve_path)", url.as_str());
// Track it so we know to clean it up
security_scoped_resources.track_manually(url.as_str().to_string());
} else {
log::warn!("Failed to start accessing security-scoped resource for URL: {}", url.as_str());
}
}
} else {
log::debug!("Failed to create NSURL from URL: {}, ignoring security-scoped resource access request", url.as_str());
}
} else {
log::debug!("Security-scoped resource already active for URL: {} (started via start_accessing_security_scoped_resource), skipping", url.as_str());
}
}
}
}
let path = path.into_path()?;
let path = if let Some(base_dir) = base_dir {
let resolved_path = if let Some(base_dir) = base_dir {
webview.path().resolve(&path, base_dir)?
} else {
path
@@ -1164,23 +1558,24 @@ pub fn resolve_path<R: Runtime>(
let require_literal_leading_dot = fs_scope.require_literal_leading_dot.unwrap_or(cfg!(unix));
if is_forbidden(&fs_scope.scope, &path, require_literal_leading_dot)
|| is_forbidden(&scope, &path, require_literal_leading_dot)
if is_forbidden(&fs_scope.scope, &resolved_path, require_literal_leading_dot)
|| is_forbidden(&scope, &resolved_path, require_literal_leading_dot)
{
return Err(CommandError::Plugin(Error::PathForbidden(path)));
return Err(CommandError::Plugin(Error::PathForbidden(resolved_path)));
}
if fs_scope.scope.is_allowed(&path) || scope.is_allowed(&path) {
Ok(path)
if fs_scope.scope.is_allowed(&resolved_path) || scope.is_allowed(&resolved_path) {
let app_handle = webview.app_handle().clone();
Ok(PathHandle::new(resolved_path, path_, app_handle))
} else {
#[cfg(not(debug_assertions))]
return Err(CommandError::Plugin(Error::PathForbidden(path)));
return Err(CommandError::Plugin(Error::PathForbidden(resolved_path)));
#[cfg(debug_assertions)]
Err(
anyhow::anyhow!(
"forbidden path: {}, maybe it is not allowed on the scope for `allow-{permission}` permission in your capability file",
path.display()
resolved_path.display()
)
)
.map_err(Into::into)
@@ -1226,20 +1621,20 @@ fn is_forbidden<P: AsRef<Path>>(
}
}
struct StdFileResource(Mutex<File>);
struct StdFileResource<R: Runtime>(Mutex<FileHandle<R>>);
impl StdFileResource {
fn new(file: File) -> Self {
Self(Mutex::new(file))
impl<R: Runtime> StdFileResource<R> {
fn new(file_handle: FileHandle<R>) -> Self {
Self(Mutex::new(file_handle))
}
fn with_lock<R, F: FnMut(&File) -> R>(&self, mut f: F) -> R {
let file = self.0.lock().unwrap();
f(&file)
fn with_lock<Ret, F: FnMut(&mut File) -> Ret>(&self, mut f: F) -> Ret {
let mut file_handle = self.0.lock().unwrap();
f(&mut file_handle)
}
}
impl Resource for StdFileResource {}
impl<R: Runtime> Resource for StdFileResource<R> {}
/// Same as [std::io::Lines] but with bytes
struct LinesBytes<T: BufRead> {
+6
View File
@@ -24,6 +24,12 @@ fn path_or_err<P: Into<FilePath>>(p: P) -> std::io::Result<PathBuf> {
}
impl<R: Runtime> Fs<R> {
/// Open a file.
///
/// # Platform-specific
///
/// - **iOS**: This method will automatically start accessing a security-scoped resource if the path is a file URL.
/// You must call `stop_accessing_security_scoped_resource` when you're done accessing the file.
pub fn open<P: Into<FilePath>>(
&self,
path: P,
+137
View File
@@ -0,0 +1,137 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use serde::de::DeserializeOwned;
use tauri::{plugin::PluginApi, AppHandle, Runtime};
use crate::{FilePath, OpenOptions};
pub struct Fs<R: Runtime> {
_phantom: std::marker::PhantomData<fn() -> R>,
}
pub fn init<R: Runtime, C: DeserializeOwned>(
_app: &AppHandle<R>,
_api: PluginApi<R, C>,
) -> crate::Result<Fs<R>> {
Ok(Fs {
_phantom: std::marker::PhantomData,
})
}
impl<R: Runtime> Fs<R> {
/// Open a file.
///
/// # Platform-specific
///
/// - **iOS**: This method will automatically start accessing a security-scoped resource if the path is a file URL.
/// You must call [`Self::stop_accessing_security_scoped_resource`] when you're done accessing the file.
pub fn open<P: Into<FilePath>>(
&self,
path: P,
opts: OpenOptions,
) -> std::io::Result<std::fs::File> {
use objc2_foundation::{NSString, NSURL};
match path.into() {
FilePath::Url(url) if url.scheme() == "file" => {
// Handle security-scoped URLs on iOS
let url_string = url.as_str();
let url_nsstring = NSString::from_str(url_string);
// Create NSURL from the URL string
// URLWithString may return None for invalid URLs, but file:// URLs should be valid
let ns_url = unsafe { NSURL::URLWithString(&url_nsstring) };
if let Some(ns_url) = ns_url {
// Start accessing the security-scoped resource
// This is required for files outside the app's sandbox (e.g., from file picker)
// Note: We don't call stopAccessingSecurityScopedResource here because
// the file handle needs to remain accessible while the File is in use.
// The access will be automatically stopped when the app is backgrounded or terminated.
unsafe {
let success = ns_url.startAccessingSecurityScopedResource();
if success {
log::debug!(
"Started accessing security-scoped resource for URL: {}",
url_string
);
} else {
log::warn!(
"Failed to start accessing security-scoped resource for URL: {}",
url_string
);
}
}
} else {
log::debug!("Failed to create NSURL from URL: {}, ignoring security-scoped resource access request", url_string);
}
// Convert URL to path and open the file
let path = url.to_file_path().map_err(|_| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid file URL")
})?;
std::fs::OpenOptions::from(opts).open(path)
}
FilePath::Url(_) => Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"cannot use a non-file URL to load files on iOS",
)),
FilePath::Path(p) => {
// Regular path, no security-scoped resource handling needed
std::fs::OpenOptions::from(opts).open(p)
}
}
}
/// Stops accessing a security-scoped resource for the given file path or URL.
/// This should be called when you're done accessing a file that was opened
/// using a security-scoped URL (e.g., from a file picker).
///
/// # Arguments
///
/// * `path` - A file path or URL that was previously accessed via `startAccessingSecurityScopedResource`
///
/// # Returns
///
/// Returns `Ok(())` if successful, or an error if the path/URL is invalid or not a file URL.
pub fn stop_accessing_security_scoped_resource<P: Into<FilePath>>(
&self,
path: P,
) -> crate::Result<()> {
use objc2_foundation::{NSString, NSURL};
let file_path = path.into();
let url_string = match file_path {
FilePath::Url(url) => {
if url.scheme() != "file" {
return Err(crate::Error::InvalidPathUrl);
}
url.as_str().to_string()
}
FilePath::Path(p) => {
// Convert path to file URL
url::Url::from_file_path(&p)
.map_err(|_| crate::Error::InvalidPathUrl)?
.as_str()
.to_string()
}
};
let url_nsstring = NSString::from_str(&url_string);
let ns_url = unsafe { NSURL::URLWithString(&url_nsstring) };
if let Some(ns_url) = ns_url {
// Stop accessing the security-scoped resource
unsafe {
ns_url.stopAccessingSecurityScopedResource();
}
} else {
return Err(crate::Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"failed to create NSURL from URL",
)));
}
Ok(())
}
}
+75 -8
View File
@@ -4,12 +4,17 @@
//! Access the file system.
// TODO(v3): consider redesign the API to implement automatic stopAccessingSecurityScopedResource on iOS
// this likely requires returning a handle to a resource so we can impl Drop for it
#![doc(
html_logo_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png",
html_favicon_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png"
)]
use std::io::Read;
#[cfg(target_os = "ios")]
use std::sync::Mutex;
use serde::Deserialize;
use tauri::{
@@ -19,24 +24,28 @@ use tauri::{
AppHandle, DragDropEvent, Manager, RunEvent, Runtime, WindowEvent,
};
#[cfg(target_os = "android")]
mod android;
mod commands;
mod config;
#[cfg(not(target_os = "android"))]
#[cfg(desktop)]
mod desktop;
mod error;
mod file_path;
#[cfg(target_os = "android")]
mod mobile;
#[cfg(target_os = "ios")]
mod ios;
#[cfg(target_os = "android")]
mod models;
mod scope;
#[cfg(feature = "watch")]
mod watcher;
#[cfg(not(target_os = "android"))]
pub use desktop::Fs;
#[cfg(target_os = "android")]
pub use mobile::Fs;
pub use android::Fs;
#[cfg(desktop)]
pub use desktop::Fs;
#[cfg(target_os = "ios")]
pub use ios::Fs;
pub use error::Error;
@@ -369,6 +378,56 @@ pub(crate) struct Scope {
pub(crate) require_literal_leading_dot: Option<bool>,
}
/// Tracks which paths have active security-scoped resource access on iOS.
#[cfg(target_os = "ios")]
pub(crate) struct SecurityScopedResources {
/// Set of file URLs that are currently accessing security-scoped resources.
/// The key is the URL string representation.
pub(crate) active_urls: Mutex<std::collections::HashSet<String>>,
}
#[cfg(target_os = "ios")]
impl SecurityScopedResources {
pub(crate) fn new() -> Self {
Self {
active_urls: Mutex::new(std::collections::HashSet::new()),
}
}
pub(crate) fn is_tracked_manually(&self, url: &str) -> bool {
self.active_urls.lock().unwrap().contains(url)
}
pub(crate) fn track_manually(&self, url: String) {
self.active_urls.lock().unwrap().insert(url);
}
pub(crate) fn remove(&self, url: &str) {
self.active_urls.lock().unwrap().remove(url);
}
}
#[cfg(not(target_os = "ios"))]
pub(crate) struct SecurityScopedResources;
#[cfg(not(target_os = "ios"))]
impl SecurityScopedResources {
pub(crate) fn new() -> Self {
Self
}
#[allow(dead_code)] // Used on iOS, but not on other platforms
pub(crate) fn is_tracked_manually(&self, _url: &str) -> bool {
false
}
#[allow(dead_code)] // Used on iOS, but not on other platforms
pub(crate) fn track_manually(&self, _url: String) {}
#[allow(dead_code)] // Used on iOS, but not on other platforms
pub(crate) fn remove(&self, _url: &str) {}
}
pub trait FsExt<R: Runtime> {
fn fs_scope(&self) -> tauri::fs::Scope;
fn try_fs_scope(&self) -> Option<tauri::fs::Scope>;
@@ -417,6 +476,8 @@ pub fn init<R: Runtime>() -> TauriPlugin<R, Option<config::Config>> {
commands::write_text_file,
commands::exists,
commands::size,
commands::start_accessing_security_scoped_resource,
commands::stop_accessing_security_scoped_resource,
#[cfg(feature = "watch")]
watcher::watch,
])
@@ -431,13 +492,19 @@ pub fn init<R: Runtime>() -> TauriPlugin<R, Option<config::Config>> {
#[cfg(target_os = "android")]
{
let fs = mobile::init(app, api)?;
let fs = android::init(app, api)?;
app.manage(fs);
}
#[cfg(not(target_os = "android"))]
#[cfg(target_os = "ios")]
{
let fs = ios::init(app, api)?;
app.manage(fs);
}
#[cfg(desktop)]
app.manage(Fs(app.clone()));
app.manage(scope);
app.manage(SecurityScopedResources::new());
Ok(())
})
.on_event(|app, event| {