mirror of
https://github.com/tauri-apps/plugins-workspace.git
synced 2026-06-04 13:48:01 +02:00
feat(fs): access security scoped resources on iOS (#3185)
Co-authored-by: FabianLars <30730186+FabianLars@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
36d3d19247
commit
f5f68063e4
@@ -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
@@ -6710,8 +6710,10 @@ dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
"glob",
|
||||
"log",
|
||||
"notify",
|
||||
"notify-debouncer-full",
|
||||
"objc2-foundation 0.3.0",
|
||||
"percent-encoding",
|
||||
"schemars",
|
||||
"serde",
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+13
@@ -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"]
|
||||
+13
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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| {
|
||||
|
||||
Reference in New Issue
Block a user