feat(fs): access security scoped resources on iOS

This commit is contained in:
Lucas Nogueira
2025-12-29 07:26:54 -03:00
parent 04b33ea0b0
commit 867eeed932
12 changed files with 296 additions and 41 deletions

1
Cargo.lock generated
View File

@@ -6626,6 +6626,7 @@ dependencies = [
"glob",
"notify",
"notify-debouncer-full",
"objc2-foundation 0.3.0",
"percent-encoding",
"schemars",
"serde",

View File

@@ -41,5 +41,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

View File

@@ -104,6 +104,7 @@ const COMMANDS: &[(&str, &[&str])] = &[
// TODO: Remove this in v3
("unwatch", &[]),
("size", &[]),
("stop_accessing_security_scoped_resource", &[]),
];
fn main() {

View File

@@ -1348,6 +1348,38 @@ async function size(path: string | URL): Promise<number> {
})
}
/**
* 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).
*
* #### Platform-specific
*
* - **iOS:** Stops accessing the security-scoped resource.
* - **Other platforms:** No-op (does nothing).
*
* @example
* ```typescript
* import { stopAccessingSecurityScopedResource } from '@tauri-apps/plugin-fs';
*
* // After you're done with a file from a file picker
* await stopAccessingSecurityScopedResource('file:///path/to/file.txt');
* ```
*
* @since 2.4.4
*/
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,
@@ -1396,5 +1428,6 @@ export {
exists,
watch,
watchImmediate,
size
size,
stopAccessingSecurityScopedResource
}

View File

@@ -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"]

View File

@@ -3461,6 +3461,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>

View File

@@ -1872,6 +1872,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",

View File

@@ -4,35 +4,26 @@
use serde::de::DeserializeOwned;
use tauri::{
plugin::{PluginApi, PluginHandle},
plugin::PluginApi,
AppHandle, Runtime,
};
use crate::{models::*, FilePath, OpenOptions};
use crate::{FilePath, OpenOptions, models::*};
#[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> {
pub fn open<P: Into<FilePath>>(
&self,
@@ -68,29 +59,26 @@ 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!()
}
}
}

View File

@@ -1010,7 +1010,32 @@ fn get_dir_size(path: &PathBuf) -> CommandResult<u64> {
Ok(size)
}
#[cfg(not(target_os = "android"))]
#[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),
SafeFilePath::Path(safe_path) => FilePath::Path(safe_path.as_ref().to_owned()),
};
webview.fs().stop_accessing_security_scoped_resource(file_path)?;
Ok(())
}
#[cfg(not(target_os = "ios"))]
{
// No-op on non-iOS platforms
let _ = webview;
let _ = path;
Ok(())
}
}
#[cfg(desktop)]
pub fn resolve_file<R: Runtime>(
permission: &str,
webview: &Webview<R>,
@@ -1057,7 +1082,7 @@ fn resolve_file_in_fs<R: Runtime>(
Ok((file, path))
}
#[cfg(target_os = "android")]
#[cfg(mobile)]
pub fn resolve_file<R: Runtime>(
permission: &str,
webview: &Webview<R>,
@@ -1095,6 +1120,27 @@ pub fn resolve_path<R: Runtime>(
path: SafeFilePath,
base_dir: Option<BaseDirectory>,
) -> CommandResult<PathBuf> {
// On iOS, start accessing security-scoped resource if the path is a file URL
#[cfg(target_os = "ios")]
{
if let SafeFilePath::Url(url) = &path {
if url.scheme() == "file" {
use objc2_foundation::{NSString, NSURL};
let url_string = url.as_str();
let url_nsstring = NSString::from_str(url_string);
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 _ = ns_url.startAccessingSecurityScopedResource();
}
}
}
}
}
let path = path.into_path()?;
let path = if let Some(base_dir) = base_dir {
webview.path().resolve(&path, base_dir)?

122
plugins/fs/src/ios.rs Normal file
View File

@@ -0,0 +1,122 @@
// 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> {
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 _ = ns_url.startAccessingSecurityScopedResource();
}
}
// 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();
}
}
Ok(())
}
}

View File

@@ -21,22 +21,26 @@ use tauri::{
mod commands;
mod config;
#[cfg(not(target_os = "android"))]
#[cfg(desktop)]
mod desktop;
#[cfg(target_os = "android")]
mod android;
#[cfg(target_os = "ios")]
mod ios;
mod error;
mod file_path;
#[cfg(target_os = "android")]
mod mobile;
#[cfg(target_os = "android")]
mod models;
mod scope;
#[cfg(feature = "watch")]
mod watcher;
#[cfg(not(target_os = "android"))]
#[cfg(desktop)]
pub use desktop::Fs;
#[cfg(target_os = "android")]
pub use mobile::Fs;
pub use android::Fs;
#[cfg(target_os = "ios")]
pub use ios::Fs;
pub use error::Error;
@@ -417,6 +421,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R, Option<config::Config>> {
commands::write_text_file,
commands::exists,
commands::size,
commands::stop_accessing_security_scoped_resource,
#[cfg(feature = "watch")]
watcher::watch,
])
@@ -431,10 +436,15 @@ 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);