use serde::{Deserialize, Serialize, Serializer};
use serde_repr::{Deserialize_repr, Serialize_repr};
use tauri::{
ipc::{CommandScope, GlobalScope},
path::BaseDirectory,
utils::config::FsScope,
Manager, Resource, ResourceId, Runtime, Webview,
};
use std::{
borrow::Cow,
fs::File,
io::{BufRead, BufReader, Read, Write},
ops::{Deref, DerefMut},
path::{Path, PathBuf},
str::FromStr,
sync::Mutex,
time::{SystemTime, UNIX_EPOCH},
};
use crate::{scope::Entry, Error, SafeFilePath};
#[derive(Debug, thiserror::Error)]
pub enum CommandError {
#[error(transparent)]
Anyhow(#[from] anyhow::Error),
#[error(transparent)]
Plugin(#[from] Error),
#[error(transparent)]
Tauri(#[from] tauri::Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
UrlParseError(#[from] url::ParseError),
#[cfg(feature = "watch")]
#[error(transparent)]
Watcher(#[from] notify::Error),
}
impl From<String> for CommandError {
fn from(value: String) -> Self {
Self::Anyhow(anyhow::anyhow!(value))
}
}
impl From<&str> for CommandError {
fn from(value: &str) -> Self {
Self::Anyhow(anyhow::anyhow!(value.to_string()))
}
}
impl Serialize for CommandError {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
if let Self::Anyhow(err) = self {
serializer.serialize_str(format!("{err:#}").as_ref())
} else {
serializer.serialize_str(self.to_string().as_ref())
}
}
}
pub type CommandResult<T> = std::result::Result<T, CommandError>;
pub enum PathKind<R: Runtime> {
#[allow(dead_code)] Path(PathBuf),
Handle(PathHandle<R>),
}
impl<R: Runtime> PathKind<R> {
pub fn as_path(&self) -> &Path {
match self {
PathKind::Path(p) => p.as_ref(),
PathKind::Handle(h) => h.as_ref(),
}
}
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()
}
}
pub struct FileHandle<R: Runtime> {
file: File,
path: PathKind<R>,
#[allow(dead_code)] path_: SafeFilePath,
#[allow(dead_code)] 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,
}
}
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")]
{
if let PathKind::Path(_) = &self.path {
use crate::{FilePath, FsExt};
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()),
};
if let FilePath::Url(url) = file_path {
if url.scheme() == "file" {
let security_scoped_resources =
self.app_handle.state::<crate::SecurityScopedResources>();
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)");
}
}
}
}
}
}
}
pub struct PathHandle<R: Runtime> {
path: PathBuf,
#[allow(dead_code)] path_: SafeFilePath,
#[allow(dead_code)] 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};
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()),
};
if let FilePath::Url(url) = file_path {
if url.scheme() == "file" {
let security_scoped_resources =
self.app_handle.state::<crate::SecurityScopedResources>();
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 {
base_dir: Option<BaseDirectory>,
}
#[tauri::command]
pub fn create<R: Runtime>(
webview: Webview<R>,
global_scope: GlobalScope<Entry>,
command_scope: CommandScope<Entry>,
path: SafeFilePath,
options: Option<BaseOptions>,
) -> CommandResult<ResourceId> {
let path_ = path.clone();
let resolved_path_handle = resolve_path(
"create",
&webview,
&global_scope,
&command_scope,
path,
options.and_then(|o| o.base_dir),
)?;
let file = File::create(&*resolved_path_handle).map_err(|e| {
format!(
"failed to create file at path: {} with error: {e}",
resolved_path_handle.display()
)
})?;
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)
}
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenOptions {
#[serde(flatten)]
base: BaseOptions,
#[serde(flatten)]
options: crate::OpenOptions,
}
#[tauri::command]
pub fn open<R: Runtime>(
webview: Webview<R>,
global_scope: GlobalScope<Entry>,
command_scope: CommandScope<Entry>,
path: SafeFilePath,
options: Option<OpenOptions>,
) -> CommandResult<ResourceId> {
let file_handle = resolve_file(
"open",
&webview,
&global_scope,
&command_scope,
path,
if let Some(opts) = options {
OpenOptions {
base: opts.base,
options: opts.options,
}
} else {
OpenOptions {
base: BaseOptions { base_dir: None },
options: crate::OpenOptions {
read: true,
write: false,
truncate: false,
create: false,
create_new: false,
append: false,
mode: None,
custom_flags: None,
},
}
},
)?;
let rid = webview
.resources_table()
.add(StdFileResource::new(file_handle));
Ok(rid)
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CopyFileOptions {
from_path_base_dir: Option<BaseDirectory>,
to_path_base_dir: Option<BaseDirectory>,
}
#[tauri::command]
pub async fn copy_file<R: Runtime>(
webview: Webview<R>,
global_scope: GlobalScope<Entry>,
command_scope: CommandScope<Entry>,
from_path: SafeFilePath,
to_path: SafeFilePath,
options: Option<CopyFileOptions>,
) -> CommandResult<()> {
let resolved_from_path = resolve_path(
"copy-file",
&webview,
&global_scope,
&command_scope,
from_path,
options.as_ref().and_then(|o| o.from_path_base_dir),
)?;
let resolved_to_path = resolve_path(
"copy-file",
&webview,
&global_scope,
&command_scope,
to_path,
options.as_ref().and_then(|o| o.to_path_base_dir),
)?;
std::fs::copy(&resolved_from_path, &resolved_to_path).map_err(|e| {
format!(
"failed to copy file from path: {}, to path: {} with error: {e}",
resolved_from_path.display(),
resolved_to_path.display()
)
})?;
Ok(())
}
#[derive(Debug, Clone, Deserialize)]
pub struct MkdirOptions {
#[serde(flatten)]
base: BaseOptions,
#[allow(unused)]
mode: Option<u32>,
recursive: Option<bool>,
}
#[tauri::command]
pub fn mkdir<R: Runtime>(
webview: Webview<R>,
global_scope: GlobalScope<Entry>,
command_scope: CommandScope<Entry>,
path: SafeFilePath,
options: Option<MkdirOptions>,
) -> CommandResult<()> {
let resolved_path = resolve_path(
"mkdir",
&webview,
&global_scope,
&command_scope,
path,
options.as_ref().and_then(|o| o.base.base_dir),
)?;
let mut builder = std::fs::DirBuilder::new();
builder.recursive(options.as_ref().and_then(|o| o.recursive).unwrap_or(false));
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
let mode = options.as_ref().and_then(|o| o.mode).unwrap_or(0o777) & 0o777;
builder.mode(mode);
}
builder
.create(&resolved_path)
.map_err(|e| {
format!(
"failed to create directory at path: {} with error: {e}",
resolved_path.display()
)
})
.map_err(Into::into)
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct DirEntry {
pub name: String,
pub is_directory: bool,
pub is_file: bool,
pub is_symlink: bool,
}
#[tauri::command]
pub async fn read_dir<R: Runtime>(
webview: Webview<R>,
global_scope: GlobalScope<Entry>,
command_scope: CommandScope<Entry>,
path: SafeFilePath,
options: Option<BaseOptions>,
) -> CommandResult<Vec<DirEntry>> {
let resolved_path = resolve_path(
"read-dir",
&webview,
&global_scope,
&command_scope,
path,
options.as_ref().and_then(|o| o.base_dir),
)?;
let entries = std::fs::read_dir(&resolved_path).map_err(|e| {
format!(
"failed to read directory at path: {} with error: {e}",
resolved_path.display()
)
})?;
let entries = entries
.filter_map(|entry| {
let entry = entry.ok()?;
let name = entry.file_name().into_string().ok()?;
let metadata = entry.file_type();
macro_rules! method_or_false {
($method:ident) => {
if let Ok(metadata) = &metadata {
metadata.$method()
} else {
false
}
};
}
Some(DirEntry {
name,
is_file: method_or_false!(is_file),
is_directory: method_or_false!(is_dir),
is_symlink: method_or_false!(is_symlink),
})
})
.collect();
Ok(entries)
}
#[tauri::command]
pub async fn read<R: Runtime>(
webview: Webview<R>,
rid: ResourceId,
len: usize,
) -> CommandResult<tauri::ipc::Response> {
let mut data = vec![0; len];
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}"))?;
#[cfg(target_pointer_width = "16")]
let nread = {
let nread = nread.to_be_bytes();
let mut out = [0; 8];
out[6..].copy_from_slice(&nread);
out
};
#[cfg(target_pointer_width = "32")]
let nread = {
let nread = nread.to_be_bytes();
let mut out = [0; 8];
out[4..].copy_from_slice(&nread);
out
};
#[cfg(target_pointer_width = "64")]
let nread = nread.to_be_bytes();
data.extend(nread);
Ok(tauri::ipc::Response::new(data))
}
async fn read_file_inner<R: Runtime>(
permission: &str,
webview: Webview<R>,
global_scope: GlobalScope<Entry>,
command_scope: CommandScope<Entry>,
path: SafeFilePath,
options: Option<BaseOptions>,
) -> CommandResult<tauri::ipc::Response> {
let mut file_handle = resolve_file(
permission,
&webview,
&global_scope,
&command_scope,
path,
OpenOptions {
base: BaseOptions {
base_dir: options.as_ref().and_then(|o| o.base_dir),
},
options: crate::OpenOptions {
read: true,
..Default::default()
},
},
)?;
let mut contents = Vec::new();
file_handle.read_to_end(&mut contents).map_err(|e| {
format!(
"failed to read file as text at path: {} with error: {e}",
file_handle.path().display()
)
})?;
Ok(tauri::ipc::Response::new(contents))
}
#[tauri::command]
pub async fn read_file<R: Runtime>(
webview: Webview<R>,
global_scope: GlobalScope<Entry>,
command_scope: CommandScope<Entry>,
path: SafeFilePath,
options: Option<BaseOptions>,
) -> CommandResult<tauri::ipc::Response> {
read_file_inner(
"read-file",
webview,
global_scope,
command_scope,
path,
options,
)
.await
}
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReadTextFileOptions {
#[serde(flatten)]
base: BaseOptions,
encoding: Option<String>,
}
#[tauri::command]
pub async fn read_text_file<R: Runtime>(
webview: Webview<R>,
global_scope: GlobalScope<Entry>,
command_scope: CommandScope<Entry>,
path: SafeFilePath,
options: Option<BaseOptions>,
) -> CommandResult<tauri::ipc::Response> {
read_file_inner(
"read-text-file",
webview,
global_scope,
command_scope,
path,
options,
)
.await
}
#[tauri::command]
pub fn read_text_file_lines<R: Runtime>(
webview: Webview<R>,
global_scope: GlobalScope<Entry>,
command_scope: CommandScope<Entry>,
path: SafeFilePath,
options: Option<ReadTextFileOptions>,
) -> CommandResult<ResourceId> {
let resolved_path = resolve_path(
"read-text-file-lines",
&webview,
&global_scope,
&command_scope,
path,
options.as_ref().and_then(|o| o.base.base_dir),
)?;
let file = File::open(&resolved_path).map_err(|e| {
format!(
"failed to open file at path: {} with error: {e}",
resolved_path.display()
)
})?;
let encoding = options.as_ref().and_then(|o| o.encoding.as_deref());
let (lf_bytes, cr_bytes) = lf_cr_bytes_for_encoding_label(encoding);
let lines = BufReader::new(file);
let rid = webview
.resources_table()
.add(StdLinesResource::new(lines, lf_bytes, cr_bytes));
Ok(rid)
}
fn lf_cr_bytes_for_encoding_label(label: Option<&str>) -> (Vec<u8>, Vec<u8>) {
let label = label.unwrap_or("utf-8");
if label == "utf-16le" {
return (vec![0x0A, 0x00], vec![0x0D, 0x00]);
}
if label == "utf-16be" {
return (vec![0x00, 0x0A], vec![0x00, 0x0D]);
}
(vec![b'\n'], vec![b'\r'])
}
#[tauri::command]
pub async fn read_text_file_lines_next<R: Runtime>(
webview: Webview<R>,
rid: ResourceId,
) -> CommandResult<tauri::ipc::Response> {
let mut resource_table = webview.resources_table();
let lines = resource_table.get::<StdLinesResource>(rid)?;
let ret = StdLinesResource::with_lock(&lines, |lines| -> CommandResult<Vec<u8>> {
match lines.next() {
Some(Ok(mut bytes)) => {
bytes.push(false as u8);
Ok(bytes)
}
Some(Err(_)) => Ok(vec![false as u8]),
None => {
resource_table.close(rid)?;
Ok(vec![true as u8])
}
}
});
ret.map(tauri::ipc::Response::new)
}
#[derive(Debug, Clone, Deserialize)]
pub struct RemoveOptions {
#[serde(flatten)]
base: BaseOptions,
recursive: Option<bool>,
}
#[tauri::command]
pub fn remove<R: Runtime>(
webview: Webview<R>,
global_scope: GlobalScope<Entry>,
command_scope: CommandScope<Entry>,
path: SafeFilePath,
options: Option<RemoveOptions>,
) -> CommandResult<()> {
let resolved_path = resolve_path(
"remove",
&webview,
&global_scope,
&command_scope,
path,
options.as_ref().and_then(|o| o.base.base_dir),
)?;
let metadata = std::fs::symlink_metadata(&resolved_path).map_err(|e| {
format!(
"failed to get metadata of path: {} with error: {e}",
resolved_path.display()
)
})?;
let file_type = metadata.file_type();
let res = if file_type.is_file() {
std::fs::remove_file(&resolved_path)
} else if options.as_ref().and_then(|o| o.recursive).unwrap_or(false) {
std::fs::remove_dir_all(&resolved_path)
} else if file_type.is_symlink() {
#[cfg(unix)]
{
std::fs::remove_file(&resolved_path)
}
#[cfg(not(unix))]
{
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x00000010;
if metadata.file_attributes() & FILE_ATTRIBUTE_DIRECTORY != 0 {
std::fs::remove_dir(&resolved_path)
} else {
std::fs::remove_file(&resolved_path)
}
}
} else if file_type.is_dir() {
std::fs::remove_dir(&resolved_path)
} else {
std::fs::remove_file(&resolved_path)
};
res.map_err(|e| {
format!(
"failed to remove path: {} with error: {e}",
resolved_path.display()
)
})
.map_err(Into::into)
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RenameOptions {
new_path_base_dir: Option<BaseDirectory>,
old_path_base_dir: Option<BaseDirectory>,
}
#[tauri::command]
pub fn rename<R: Runtime>(
webview: Webview<R>,
global_scope: GlobalScope<Entry>,
command_scope: CommandScope<Entry>,
old_path: SafeFilePath,
new_path: SafeFilePath,
options: Option<RenameOptions>,
) -> CommandResult<()> {
let resolved_old_path = resolve_path(
"rename",
&webview,
&global_scope,
&command_scope,
old_path,
options.as_ref().and_then(|o| o.old_path_base_dir),
)?;
let resolved_new_path = resolve_path(
"rename",
&webview,
&global_scope,
&command_scope,
new_path,
options.as_ref().and_then(|o| o.new_path_base_dir),
)?;
std::fs::rename(&resolved_old_path, &resolved_new_path)
.map_err(|e| {
format!(
"failed to rename old path: {} to new path: {} with error: {e}",
resolved_old_path.display(),
resolved_new_path.display()
)
})
.map_err(Into::into)
}
#[derive(Serialize_repr, Deserialize_repr, Clone, Copy, Debug)]
#[repr(u16)]
pub enum SeekMode {
Start = 0,
Current = 1,
End = 2,
}
#[tauri::command]
pub async fn seek<R: Runtime>(
webview: Webview<R>,
rid: ResourceId,
offset: i64,
whence: SeekMode,
) -> CommandResult<u64> {
use std::io::{Seek, SeekFrom};
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),
SeekMode::End => SeekFrom::End(offset),
})
})
.map_err(|e| format!("failed to seek file with error: {e}"))
.map_err(Into::into)
}
#[cfg(target_os = "android")]
fn get_metadata<R: Runtime, F: FnOnce(&PathBuf) -> std::io::Result<std::fs::Metadata>>(
permission: &str,
metadata_fn: F,
webview: &Webview<R>,
global_scope: &GlobalScope<Entry>,
command_scope: &CommandScope<Entry>,
path: SafeFilePath,
options: Option<BaseOptions>,
) -> CommandResult<std::fs::Metadata> {
match path {
SafeFilePath::Url(url) => {
let file_handle = resolve_file(
permission,
webview,
global_scope,
command_scope,
SafeFilePath::Url(url),
OpenOptions {
base: BaseOptions { base_dir: None },
options: crate::OpenOptions {
read: true,
..Default::default()
},
},
)?;
file_handle.metadata().map_err(|e| {
format!(
"failed to get metadata of path: {} with error: {e}",
file_handle.path().display()
)
.into()
})
}
SafeFilePath::Path(p) => get_fs_metadata(
permission,
metadata_fn,
webview,
global_scope,
command_scope,
SafeFilePath::Path(p),
options,
),
}
}
#[cfg(not(target_os = "android"))]
fn get_metadata<R: Runtime, F: FnOnce(&PathBuf) -> std::io::Result<std::fs::Metadata>>(
permission: &str,
metadata_fn: F,
webview: &Webview<R>,
global_scope: &GlobalScope<Entry>,
command_scope: &CommandScope<Entry>,
path: SafeFilePath,
options: Option<BaseOptions>,
) -> CommandResult<std::fs::Metadata> {
get_fs_metadata(
permission,
metadata_fn,
webview,
global_scope,
command_scope,
path,
options,
)
}
fn get_fs_metadata<R: Runtime, F: FnOnce(&PathBuf) -> std::io::Result<std::fs::Metadata>>(
permission: &str,
metadata_fn: F,
webview: &Webview<R>,
global_scope: &GlobalScope<Entry>,
command_scope: &CommandScope<Entry>,
path: SafeFilePath,
options: Option<BaseOptions>,
) -> CommandResult<std::fs::Metadata> {
let resolved_path = resolve_path(
permission,
webview,
global_scope,
command_scope,
path,
options.as_ref().and_then(|o| o.base_dir),
)?;
let metadata = metadata_fn(&resolved_path).map_err(|e| {
format!(
"failed to get metadata of path: {} with error: {e}",
resolved_path.display()
)
})?;
Ok(metadata)
}
#[tauri::command]
pub fn stat<R: Runtime>(
webview: Webview<R>,
global_scope: GlobalScope<Entry>,
command_scope: CommandScope<Entry>,
path: SafeFilePath,
options: Option<BaseOptions>,
) -> CommandResult<FileInfo> {
let metadata = get_metadata(
"stat",
|p| std::fs::metadata(p),
&webview,
&global_scope,
&command_scope,
path,
options,
)?;
Ok(get_stat(metadata))
}
#[tauri::command]
pub fn lstat<R: Runtime>(
webview: Webview<R>,
global_scope: GlobalScope<Entry>,
command_scope: CommandScope<Entry>,
path: SafeFilePath,
options: Option<BaseOptions>,
) -> CommandResult<FileInfo> {
let metadata = get_metadata(
"lstat",
|p| std::fs::symlink_metadata(p),
&webview,
&global_scope,
&command_scope,
path,
options,
)?;
Ok(get_stat(metadata))
}
#[tauri::command]
pub fn fstat<R: Runtime>(webview: Webview<R>, rid: ResourceId) -> CommandResult<FileInfo> {
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))
}
#[tauri::command]
pub async fn truncate<R: Runtime>(
webview: Webview<R>,
global_scope: GlobalScope<Entry>,
command_scope: CommandScope<Entry>,
path: SafeFilePath,
len: Option<u64>,
options: Option<BaseOptions>,
) -> CommandResult<()> {
let resolved_path = resolve_path(
"truncate",
&webview,
&global_scope,
&command_scope,
path,
options.as_ref().and_then(|o| o.base_dir),
)?;
let f = std::fs::OpenOptions::new()
.write(true)
.open(&resolved_path)
.map_err(|e| {
format!(
"failed to open file at path: {} with error: {e}",
resolved_path.display()
)
})?;
f.set_len(len.unwrap_or(0))
.map_err(|e| {
format!(
"failed to truncate file at path: {} with error: {e}",
resolved_path.display()
)
})
.map_err(Into::into)
}
#[tauri::command]
pub async fn ftruncate<R: Runtime>(
webview: Webview<R>,
rid: ResourceId,
len: Option<u64>,
) -> CommandResult<()> {
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)
}
#[tauri::command]
pub async fn write<R: Runtime>(
webview: Webview<R>,
rid: ResourceId,
data: Vec<u8>,
) -> CommandResult<usize> {
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)
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WriteFileOptions {
#[serde(flatten)]
base: BaseOptions,
#[serde(default)]
append: bool,
#[serde(default = "default_create_value")]
create: bool,
#[serde(default)]
create_new: bool,
#[allow(unused)]
mode: Option<u32>,
}
fn default_create_value() -> bool {
true
}
async fn write_file_inner<R: Runtime>(
permission: &str,
webview: Webview<R>,
global_scope: GlobalScope<Entry>,
command_scope: CommandScope<Entry>,
request: tauri::ipc::Request<'_>,
) -> CommandResult<()> {
let path = request
.headers()
.get("path")
.ok_or_else(|| anyhow::anyhow!("missing file path").into())
.and_then(|p| {
percent_encoding::percent_decode(p.as_ref())
.decode_utf8()
.map_err(|_| anyhow::anyhow!("path is not a valid UTF-8").into())
})
.and_then(|p| SafeFilePath::from_str(&p).map_err(CommandError::from))?;
let options: Option<WriteFileOptions> = request
.headers()
.get("options")
.and_then(|p| p.to_str().ok())
.and_then(|opts| serde_json::from_str(opts).ok());
let mut file_handle = resolve_file(
permission,
&webview,
&global_scope,
&command_scope,
path,
if let Some(opts) = options {
OpenOptions {
base: opts.base,
options: crate::OpenOptions {
read: false,
write: true,
create: opts.create,
truncate: !opts.append,
append: opts.append,
create_new: opts.create_new,
mode: opts.mode,
custom_flags: None,
},
}
} else {
OpenOptions {
base: BaseOptions { base_dir: None },
options: crate::OpenOptions {
read: false,
write: true,
truncate: true,
create: true,
create_new: false,
append: false,
mode: None,
custom_flags: None,
},
}
},
)?;
let data = match request.body() {
tauri::ipc::InvokeBody::Raw(data) => Cow::Borrowed(data),
tauri::ipc::InvokeBody::Json(serde_json::Value::Array(data)) => Cow::Owned(
data.iter()
.flat_map(|v| v.as_number().and_then(|v| v.as_u64().map(|v| v as u8)))
.collect(),
),
_ => return Err(anyhow::anyhow!("unexpected invoke body").into()),
};
file_handle
.write_all(&data)
.map_err(|e| {
format!(
"failed to write bytes to file at path: {} with error: {e}",
file_handle.path().display()
)
})
.map_err(Into::into)
}
#[tauri::command]
pub async fn write_file<R: Runtime>(
webview: Webview<R>,
global_scope: GlobalScope<Entry>,
command_scope: CommandScope<Entry>,
request: tauri::ipc::Request<'_>,
) -> CommandResult<()> {
write_file_inner("write-file", webview, global_scope, command_scope, request).await
}
#[tauri::command]
pub async fn write_text_file<R: Runtime>(
webview: Webview<R>,
global_scope: GlobalScope<Entry>,
command_scope: CommandScope<Entry>,
request: tauri::ipc::Request<'_>,
) -> CommandResult<()> {
write_file_inner(
"write-text-file",
webview,
global_scope,
command_scope,
request,
)
.await
}
#[tauri::command]
pub fn exists<R: Runtime>(
webview: Webview<R>,
global_scope: GlobalScope<Entry>,
command_scope: CommandScope<Entry>,
path: SafeFilePath,
options: Option<BaseOptions>,
) -> CommandResult<bool> {
let resolved_path = resolve_path(
"exists",
&webview,
&global_scope,
&command_scope,
path,
options.as_ref().and_then(|o| o.base_dir),
)?;
Ok(resolved_path.exists())
}
#[tauri::command]
pub async fn size<R: Runtime>(
webview: Webview<R>,
global_scope: GlobalScope<Entry>,
command_scope: CommandScope<Entry>,
path: SafeFilePath,
options: Option<BaseOptions>,
) -> CommandResult<u64> {
let resolved_path = resolve_path(
"size",
&webview,
&global_scope,
&command_scope,
path,
options.as_ref().and_then(|o| o.base_dir),
)?;
let metadata = resolved_path.metadata()?;
if metadata.is_file() {
Ok(metadata.len())
} else {
let size = get_dir_size(&resolved_path).map_err(|e| {
format!(
"failed to get size at path: {} with error: {e}",
resolved_path.display()
)
})?;
Ok(size)
}
}
#[tauri::command]
pub fn start_accessing_security_scoped_resource<R: Runtime>(
webview: Webview<R>,
path: SafeFilePath,
) -> CommandResult<()> {
#[cfg(target_os = "ios")]
{
use crate::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()),
};
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 {
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(());
}
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"))]
{
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};
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()),
};
if let FilePath::Url(url) = file_path {
if url.scheme() == "file" {
let security_scoped_resources = webview.state::<crate::SecurityScopedResources>();
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(());
}
webview
.fs()
.stop_accessing_security_scoped_resource(FilePath::Url(url.clone()))?;
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"))]
{
let _ = webview;
let _ = path;
Ok(())
}
}
fn get_dir_size(path: &PathBuf) -> CommandResult<u64> {
let mut size = 0;
for entry in std::fs::read_dir(path)? {
let entry = entry?;
let metadata = entry.metadata()?;
if metadata.is_file() {
size += metadata.len();
} else if metadata.is_dir() {
size += get_dir_size(&entry.path())?;
}
}
Ok(size)
}
#[cfg(desktop)]
pub fn resolve_file<R: Runtime>(
permission: &str,
webview: &Webview<R>,
global_scope: &GlobalScope<Entry>,
command_scope: &CommandScope<Entry>,
path: SafeFilePath,
open_options: OpenOptions,
) -> CommandResult<FileHandle<R>> {
resolve_file_in_fs(
permission,
webview,
global_scope,
command_scope,
path,
open_options,
)
}
fn resolve_file_in_fs<R: Runtime>(
permission: &str,
webview: &Webview<R>,
global_scope: &GlobalScope<Entry>,
command_scope: &CommandScope<Entry>,
path: SafeFilePath,
open_options: OpenOptions,
) -> CommandResult<FileHandle<R>> {
let path_ = path.clone();
let resolved_path_handle = resolve_path(
permission,
webview,
global_scope,
command_scope,
path,
open_options.base.base_dir,
)?;
let file = std::fs::OpenOptions::from(open_options.options)
.open(&*resolved_path_handle)
.map_err(|e| {
format!(
"failed to open file at path: {} with error: {e}",
resolved_path_handle.display()
)
})?;
let app_handle = webview.app_handle().clone();
Ok(FileHandle::new(
file,
PathKind::Handle(resolved_path_handle),
path_,
app_handle,
))
}
#[cfg(mobile)]
pub fn resolve_file<R: Runtime>(
permission: &str,
webview: &Webview<R>,
global_scope: &GlobalScope<Entry>,
command_scope: &CommandScope<Entry>,
path: SafeFilePath,
open_options: OpenOptions,
) -> CommandResult<FileHandle<R>> {
use crate::FsExt;
let path_ = path.clone();
match path {
SafeFilePath::Url(url) => {
let resolved_path = url.as_str().into();
let file = webview
.fs()
.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,
webview,
global_scope,
command_scope,
SafeFilePath::Path(path),
open_options,
),
}
}
pub fn resolve_path<R: Runtime>(
permission: &str,
webview: &Webview<R>,
global_scope: &GlobalScope<Entry>,
command_scope: &CommandScope<Entry>,
path: SafeFilePath,
base_dir: Option<BaseDirectory>,
) -> CommandResult<PathHandle<R>> {
let path_ = path.clone();
#[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>();
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 {
unsafe {
let success = ns_url.startAccessingSecurityScopedResource();
if success {
log::debug!("Started accessing security-scoped resource for URL: {} (via resolve_path)", 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());
}
}
} 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 resolved_path = if let Some(base_dir) = base_dir {
webview.path().resolve(&path, base_dir)?
} else {
path
};
let fs_scope = webview.state::<crate::Scope>();
let scope = tauri::scope::fs::Scope::new(
webview,
&FsScope::Scope {
allow: global_scope
.allows()
.iter()
.filter_map(|e| e.path.clone())
.chain(command_scope.allows().iter().filter_map(|e| e.path.clone()))
.collect(),
deny: global_scope
.denies()
.iter()
.filter_map(|e| e.path.clone())
.chain(command_scope.denies().iter().filter_map(|e| e.path.clone()))
.collect(),
require_literal_leading_dot: fs_scope.require_literal_leading_dot,
},
)?;
let require_literal_leading_dot = fs_scope.require_literal_leading_dot.unwrap_or(cfg!(unix));
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(resolved_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(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",
resolved_path.display()
)
)
.map_err(Into::into)
}
}
fn is_forbidden<P: AsRef<Path>>(
scope: &tauri::fs::Scope,
path: P,
require_literal_leading_dot: bool,
) -> bool {
let path = path.as_ref();
let path = if path.is_symlink() {
match std::fs::read_link(path) {
Ok(p) => p,
Err(_) => return false,
}
} else {
path.to_path_buf()
};
let path = if !path.exists() {
crate::Result::Ok(path)
} else {
std::fs::canonicalize(path).map_err(Into::into)
};
if let Ok(path) = path {
let path: PathBuf = path.components().collect();
scope.forbidden_patterns().iter().any(|p| {
p.matches_path_with(
&path,
glob::MatchOptions {
require_literal_separator: true,
require_literal_leading_dot,
..Default::default()
},
)
})
} else {
false
}
}
struct StdFileResource<R: Runtime>(Mutex<FileHandle<R>>);
impl<R: Runtime> StdFileResource<R> {
fn new(file_handle: FileHandle<R>) -> Self {
Self(Mutex::new(file_handle))
}
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<R: Runtime> Resource for StdFileResource<R> {}
struct LinesBytes<T: BufRead> {
bytes: T,
lf_bytes: Vec<u8>,
cr_bytes: Vec<u8>,
}
impl<T: BufRead> LinesBytes<T> {
fn new(bytes: T, lf_bytes: Vec<u8>, cr_bytes: Vec<u8>) -> Self {
LinesBytes {
bytes,
lf_bytes,
cr_bytes,
}
}
}
impl<B: BufRead> Iterator for LinesBytes<B> {
type Item = std::io::Result<Vec<u8>>;
fn next(&mut self) -> Option<std::io::Result<Vec<u8>>> {
let mut buf = Vec::new();
match read_until_bytes(&mut self.bytes, &self.lf_bytes, &mut buf) {
Ok(0) => None,
Ok(_n) => {
if buf.ends_with(&self.lf_bytes) {
buf.truncate(buf.len() - self.lf_bytes.len());
if buf.ends_with(&self.cr_bytes) {
buf.truncate(buf.len() - self.cr_bytes.len());
}
}
Some(Ok(buf))
}
Err(e) => Some(Err(e)),
}
}
}
fn read_until_bytes(
r: &mut impl BufRead,
bytes: &[u8],
buf: &mut Vec<u8>,
) -> std::io::Result<usize> {
let last_byte = *bytes
.last()
.ok_or_else(|| std::io::Error::other("invalid empty bytes"))?;
if bytes.len() == 1 {
return r.read_until(last_byte, buf);
}
let mut total_n = 0;
loop {
let n = r.read_until(last_byte, buf)?;
total_n += n;
if n == 0 || buf.ends_with(bytes) {
return Ok(total_n);
}
}
}
struct StdLinesResource(Mutex<LinesBytes<BufReader<File>>>);
impl StdLinesResource {
fn new(lines: BufReader<File>, lf_bytes: Vec<u8>, cr_bytes: Vec<u8>) -> Self {
Self(Mutex::new(LinesBytes::new(lines, lf_bytes, cr_bytes)))
}
fn with_lock<R, F: FnMut(&mut LinesBytes<BufReader<File>>) -> R>(&self, mut f: F) -> R {
let mut lines = self.0.lock().unwrap();
f(&mut lines)
}
}
impl Resource for StdLinesResource {}
#[inline]
fn to_msec(maybe_time: std::result::Result<SystemTime, std::io::Error>) -> Option<u64> {
match maybe_time {
Ok(time) => {
let msec = time
.duration_since(UNIX_EPOCH)
.map(|t| t.as_millis() as u64)
.unwrap_or_else(|err| err.duration().as_millis() as u64);
Some(msec)
}
Err(_) => None,
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FileInfo {
is_file: bool,
is_directory: bool,
is_symlink: bool,
size: u64,
mtime: Option<u64>,
atime: Option<u64>,
birthtime: Option<u64>,
readonly: bool,
file_attribues: Option<u32>,
dev: Option<u64>,
ino: Option<u64>,
mode: Option<u32>,
nlink: Option<u64>,
uid: Option<u32>,
gid: Option<u32>,
rdev: Option<u64>,
blksize: Option<u64>,
blocks: Option<u64>,
}
#[inline(always)]
fn get_stat(metadata: std::fs::Metadata) -> FileInfo {
macro_rules! usm {
($member:ident) => {{
#[cfg(unix)]
{
Some(metadata.$member())
}
#[cfg(not(unix))]
{
None
}
}};
}
#[cfg(unix)]
use std::os::unix::fs::MetadataExt;
#[cfg(windows)]
use std::os::windows::fs::MetadataExt;
FileInfo {
is_file: metadata.is_file(),
is_directory: metadata.is_dir(),
is_symlink: metadata.file_type().is_symlink(),
size: metadata.len(),
mtime: to_msec(metadata.modified()),
atime: to_msec(metadata.accessed()),
birthtime: to_msec(metadata.created()),
readonly: metadata.permissions().readonly(),
#[cfg(windows)]
file_attribues: Some(metadata.file_attributes()),
#[cfg(not(windows))]
file_attribues: None,
dev: usm!(dev),
ino: usm!(ino),
mode: usm!(mode),
nlink: usm!(nlink),
uid: usm!(uid),
gid: usm!(gid),
rdev: usm!(rdev),
blksize: usm!(blksize),
blocks: usm!(blocks),
}
}
#[cfg(test)]
mod test {
use std::io::{BufRead, BufReader};
use super::LinesBytes;
#[test]
fn safe_file_path_parse() {
use super::SafeFilePath;
assert!(matches!(
serde_json::from_str::<SafeFilePath>("\"C:/Users\""),
Ok(SafeFilePath::Path(_))
));
assert!(matches!(
serde_json::from_str::<SafeFilePath>("\"file:///C:/Users\""),
Ok(SafeFilePath::Url(_))
));
}
#[test]
fn test_lines_bytes() {
{
let base = String::from("line 1\nline2\nline 3\r\nline 4");
let bytes = base.as_bytes();
let string1 = base.lines().collect::<String>();
let string2 = BufReader::new(bytes)
.lines()
.map_while(Result::ok)
.collect::<String>();
let string3 = LinesBytes::new(BufReader::new(bytes), vec![b'\n'], vec![b'\r'])
.flatten()
.flat_map(String::from_utf8)
.collect::<String>();
assert_eq!(string1, string2);
assert_eq!(string1, string3);
assert_eq!(string2, string3);
}
{
fn utf16(text: &str) -> Vec<u8> {
text.encode_utf16().flat_map(|u| u.to_le_bytes()).collect()
}
let base = String::from("line 1\nline2\nline 3\r\nline 4\n");
let bytes = utf16(&base);
let mut lines = LinesBytes::new(BufReader::new(&bytes[..]), utf16("\n"), utf16("\r"));
assert_eq!(lines.next().map(Result::unwrap), Some(utf16("line 1")));
assert_eq!(lines.next().map(Result::unwrap), Some(utf16("line2")));
assert_eq!(lines.next().map(Result::unwrap), Some(utf16("line 3")));
assert_eq!(lines.next().map(Result::unwrap), Some(utf16("line 4")));
assert!(lines.next().is_none());
}
{
fn utf16(text: &str) -> Vec<u8> {
text.encode_utf16().flat_map(|u| u.to_be_bytes()).collect()
}
let base = String::from("line 1\nline2ਗ\nline 3\r\nline 4");
let bytes = utf16(&base);
let mut lines = LinesBytes::new(BufReader::new(&bytes[..]), utf16("\n"), utf16("\r"));
assert_eq!(lines.next().map(Result::unwrap), Some(utf16("line 1")));
assert_eq!(lines.next().map(Result::unwrap), Some(utf16("line2ਗ")));
assert_eq!(lines.next().map(Result::unwrap), Some(utf16("line 3")));
assert_eq!(lines.next().map(Result::unwrap), Some(utf16("line 4")));
assert!(lines.next().is_none());
}
}
}