diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..e9e747f --- /dev/null +++ b/src/commands.rs @@ -0,0 +1,982 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// Copyright 2018-2023 the Deno authors. +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::{Deserialize, Serialize, Serializer}; +use serde_repr::{Deserialize_repr, Serialize_repr}; +use tauri::{ + ipc::{CommandScope, GlobalScope}, + path::{BaseDirectory, SafePathBuf}, + utils::config::FsScope, + AppHandle, Manager, Resource, ResourceId, Runtime, +}; + +use std::{ + fs::File, + io::{BufReader, Lines, Read, Write}, + path::{Path, PathBuf}, + sync::Mutex, + time::{SystemTime, UNIX_EPOCH}, +}; + +use crate::{scope::Entry, Error, FsExt}; + +#[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)] + UrlParseError(#[from] url::ParseError), + #[cfg(feature = "watch")] + #[error(transparent)] + Watcher(#[from] notify::Error), +} + +impl From 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(&self, serializer: S) -> std::result::Result + 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 = std::result::Result; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BaseOptions { + base_dir: Option, +} + +#[tauri::command] +pub fn create( + app: AppHandle, + global_scope: GlobalScope, + command_scope: CommandScope, + path: SafePathBuf, + options: Option, +) -> CommandResult { + let resolved_path = resolve_path( + &app, + &global_scope, + &command_scope, + path, + options.and_then(|o| o.base_dir), + )?; + let file = File::create(&resolved_path).map_err(|e| { + format!( + "failed to create file at path: {} with error: {e}", + resolved_path.display() + ) + })?; + let rid = app.resources_table().add(StdFileResource::new(file)); + Ok(rid) +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenOptions { + #[serde(flatten)] + base: BaseOptions, + #[serde(default = "default_true")] + read: bool, + #[serde(default)] + write: bool, + #[serde(default)] + append: bool, + #[serde(default)] + truncate: bool, + #[serde(default)] + create: bool, + #[serde(default)] + create_new: bool, + #[allow(unused)] + mode: Option, +} + +fn default_true() -> bool { + true +} + +#[tauri::command] +pub fn open( + app: AppHandle, + global_scope: GlobalScope, + command_scope: CommandScope, + path: SafePathBuf, + options: Option, +) -> CommandResult { + let resolved_path = resolve_path( + &app, + &global_scope, + &command_scope, + path, + options.as_ref().and_then(|o| o.base.base_dir), + )?; + + let mut opts = std::fs::OpenOptions::new(); + // default to read-only + opts.read(true); + + if let Some(options) = options { + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + if let Some(mode) = options.mode { + opts.mode(mode); + } + } + + opts.read(options.read) + .create(options.create) + .write(options.write) + .truncate(options.truncate) + .append(options.append) + .create_new(options.create_new); + } + + let file = opts.open(&resolved_path).map_err(|e| { + format!( + "failed to open file at path: {} with error: {e}", + resolved_path.display() + ) + })?; + + let rid = app.resources_table().add(StdFileResource::new(file)); + + Ok(rid) +} + +#[tauri::command] +pub fn close(app: AppHandle, rid: ResourceId) -> CommandResult<()> { + app.resources_table().close(rid).map_err(Into::into) +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CopyFileOptions { + from_path_base_dir: Option, + to_path_base_dir: Option, +} + +#[tauri::command] +pub fn copy_file( + app: AppHandle, + global_scope: GlobalScope, + command_scope: CommandScope, + from_path: SafePathBuf, + to_path: SafePathBuf, + options: Option, +) -> CommandResult<()> { + let resolved_from_path = resolve_path( + &app, + &global_scope, + &command_scope, + from_path, + options.as_ref().and_then(|o| o.from_path_base_dir), + )?; + let resolved_to_path = resolve_path( + &app, + &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, + recursive: Option, +} + +#[tauri::command] +pub fn mkdir( + app: AppHandle, + global_scope: GlobalScope, + command_scope: CommandScope, + path: SafePathBuf, + options: Option, +) -> CommandResult<()> { + let resolved_path = resolve_path( + &app, + &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: Option, + pub is_directory: bool, + pub is_file: bool, + pub is_symlink: bool, +} + +fn read_dir_inner>(path: P) -> crate::Result> { + let mut files_and_dirs: Vec = vec![]; + for entry in std::fs::read_dir(path)? { + let path = entry?.path(); + let file_type = path.metadata()?.file_type(); + files_and_dirs.push(DirEntry { + is_directory: file_type.is_dir(), + is_file: file_type.is_file(), + is_symlink: std::fs::symlink_metadata(&path) + .map(|md| md.file_type().is_symlink()) + .unwrap_or(false), + name: path + .file_name() + .map(|name| name.to_string_lossy()) + .map(|name| name.to_string()), + }); + } + Result::Ok(files_and_dirs) +} + +#[tauri::command] +pub fn read_dir( + app: AppHandle, + global_scope: GlobalScope, + command_scope: CommandScope, + path: SafePathBuf, + options: Option, +) -> CommandResult> { + let resolved_path = resolve_path( + &app, + &global_scope, + &command_scope, + path, + options.as_ref().and_then(|o| o.base_dir), + )?; + + read_dir_inner(&resolved_path) + .map_err(|e| { + format!( + "failed to read directory at path: {} with error: {e}", + resolved_path.display() + ) + }) + .map_err(Into::into) +} + +#[tauri::command] +pub fn read( + app: AppHandle, + rid: ResourceId, + len: u32, +) -> CommandResult<(Vec, usize)> { + let mut data = vec![0; len as usize]; + let file = app.resources_table().get::(rid)?; + let nread = StdFileResource::with_lock(&file, |mut file| file.read(&mut data)) + .map_err(|e| format!("faied to read bytes from file with error: {e}"))?; + Ok((data, nread)) +} + +#[tauri::command] +pub fn read_file( + app: AppHandle, + global_scope: GlobalScope, + command_scope: CommandScope, + path: SafePathBuf, + options: Option, +) -> CommandResult> { + let resolved_path = resolve_path( + &app, + &global_scope, + &command_scope, + path, + options.as_ref().and_then(|o| o.base_dir), + )?; + std::fs::read(&resolved_path) + .map_err(|e| { + format!( + "failed to read file at path: {} with error: {e}", + resolved_path.display() + ) + }) + .map_err(Into::into) +} + +#[tauri::command] +pub fn read_text_file( + app: AppHandle, + global_scope: GlobalScope, + command_scope: CommandScope, + path: SafePathBuf, + options: Option, +) -> CommandResult { + let resolved_path = resolve_path( + &app, + &global_scope, + &command_scope, + path, + options.as_ref().and_then(|o| o.base_dir), + )?; + std::fs::read_to_string(&resolved_path) + .map_err(|e| { + format!( + "failed to read file as text at path: {} with error: {e}", + resolved_path.display() + ) + }) + .map_err(Into::into) +} + +#[tauri::command] +pub fn read_text_file_lines( + app: AppHandle, + global_scope: GlobalScope, + command_scope: CommandScope, + path: SafePathBuf, + options: Option, +) -> CommandResult { + use std::io::BufRead; + + let resolved_path = resolve_path( + &app, + &global_scope, + &command_scope, + path, + options.as_ref().and_then(|o| o.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 lines = BufReader::new(file).lines(); + let rid = app.resources_table().add(StdLinesResource::new(lines)); + + Ok(rid) +} + +#[tauri::command] +pub fn read_text_file_lines_next( + app: AppHandle, + rid: ResourceId, +) -> CommandResult<(Option, bool)> { + let mut resource_table = app.resources_table(); + let lines = resource_table.get::(rid)?; + + let ret = StdLinesResource::with_lock(&lines, |lines| { + lines.next().map(|a| (a.ok(), false)).unwrap_or_else(|| { + let _ = resource_table.close(rid); + (None, true) + }) + }); + + Ok(ret) +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RemoveOptions { + #[serde(flatten)] + base: BaseOptions, + recursive: Option, +} + +#[tauri::command] +pub fn remove( + app: AppHandle, + global_scope: GlobalScope, + command_scope: CommandScope, + path: SafePathBuf, + options: Option, +) -> CommandResult<()> { + let resolved_path = resolve_path( + &app, + &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(); + + // taken from deno source code: https://github.com/denoland/deno/blob/429759fe8b4207240709c240a8344d12a1e39566/runtime/ops/fs.rs#L728 + 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 { + // pipes, sockets, etc... + 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, + old_path_base_dir: Option, +} + +#[tauri::command] +pub fn rename( + app: AppHandle, + global_scope: GlobalScope, + command_scope: CommandScope, + old_path: SafePathBuf, + new_path: SafePathBuf, + options: Option, +) -> CommandResult<()> { + let resolved_old_path = resolve_path( + &app, + &global_scope, + &command_scope, + old_path, + options.as_ref().and_then(|o| o.old_path_base_dir), + )?; + let resolved_new_path = resolve_path( + &app, + &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 fn seek( + app: AppHandle, + rid: ResourceId, + offset: i64, + whence: SeekMode, +) -> CommandResult { + use std::io::{Seek, SeekFrom}; + let file = app.resources_table().get::(rid)?; + StdFileResource::with_lock(&file, |mut 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) +} + +#[tauri::command] +pub fn stat( + app: AppHandle, + global_scope: GlobalScope, + command_scope: CommandScope, + path: SafePathBuf, + options: Option, +) -> CommandResult { + let resolved_path = resolve_path( + &app, + &global_scope, + &command_scope, + path, + options.as_ref().and_then(|o| o.base_dir), + )?; + let metadata = std::fs::metadata(&resolved_path).map_err(|e| { + format!( + "failed to get metadata of path: {} with error: {e}", + resolved_path.display() + ) + })?; + Ok(get_stat(metadata)) +} + +#[tauri::command] +pub fn lstat( + app: AppHandle, + global_scope: GlobalScope, + command_scope: CommandScope, + path: SafePathBuf, + options: Option, +) -> CommandResult { + let resolved_path = resolve_path( + &app, + &global_scope, + &command_scope, + path, + options.as_ref().and_then(|o| o.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() + ) + })?; + Ok(get_stat(metadata)) +} + +#[tauri::command] +pub fn fstat(app: AppHandle, rid: ResourceId) -> CommandResult { + let file = app.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 fn truncate( + app: AppHandle, + global_scope: GlobalScope, + command_scope: CommandScope, + path: SafePathBuf, + len: Option, + options: Option, +) -> CommandResult<()> { + let resolved_path = resolve_path( + &app, + &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 fn ftruncate( + app: AppHandle, + rid: ResourceId, + len: Option, +) -> CommandResult<()> { + let file = app.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 fn write( + app: AppHandle, + rid: ResourceId, + data: Vec, +) -> CommandResult { + let file = app.resources_table().get::(rid)?; + StdFileResource::with_lock(&file, |mut 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, +} + +fn default_create_value() -> bool { + true +} + +fn write_file_inner( + app: AppHandle, + global_scope: &GlobalScope, + command_scope: &CommandScope, + path: SafePathBuf, + data: &[u8], + options: Option, +) -> CommandResult<()> { + let resolved_path = resolve_path( + &app, + global_scope, + command_scope, + path, + options.as_ref().and_then(|o| o.base.base_dir), + )?; + + let mut opts = std::fs::OpenOptions::new(); + // defaults + opts.read(false).write(true).truncate(true).create(true); + + if let Some(options) = options { + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + if let Some(mode) = options.mode { + opts.mode(mode); + } + } + + opts.create(options.create) + .append(options.append) + .truncate(!options.append) + .create_new(options.create_new); + } + + let mut file = opts.open(&resolved_path).map_err(|e| { + format!( + "failed to open file at path: {} with error: {e}", + resolved_path.display() + ) + })?; + + file.write_all(data) + .map_err(|e| { + format!( + "failed to write bytes to file at path: {} with error: {e}", + resolved_path.display() + ) + }) + .map_err(Into::into) +} + +#[tauri::command] +pub fn write_file( + app: AppHandle, + global_scope: GlobalScope, + command_scope: CommandScope, + path: SafePathBuf, + data: Vec, + options: Option, +) -> CommandResult<()> { + write_file_inner(app, &global_scope, &command_scope, path, &data, options) +} + +#[tauri::command] +pub fn write_text_file( + app: AppHandle, + global_scope: GlobalScope, + command_scope: CommandScope, + path: SafePathBuf, + data: String, + options: Option, +) -> CommandResult<()> { + write_file_inner( + app, + &global_scope, + &command_scope, + path, + data.as_bytes(), + options, + ) +} + +#[tauri::command] +pub fn exists( + app: AppHandle, + global_scope: GlobalScope, + command_scope: CommandScope, + path: SafePathBuf, + options: Option, +) -> CommandResult { + let resolved_path = resolve_path( + &app, + &global_scope, + &command_scope, + path, + options.as_ref().and_then(|o| o.base_dir), + )?; + Ok(resolved_path.exists()) +} + +pub fn resolve_path( + app: &AppHandle, + global_scope: &GlobalScope, + command_scope: &CommandScope, + path: SafePathBuf, + base_dir: Option, +) -> CommandResult { + let path = file_url_to_safe_pathbuf(path)?; + let path = if let Some(base_dir) = base_dir { + app.path().resolve(&path, base_dir)? + } else { + path.as_ref().to_path_buf() + }; + + let scope = tauri::scope::fs::Scope::new( + app, + &FsScope::Scope { + allow: app + .fs_scope() + .allowed + .lock() + .unwrap() + .clone() + .into_iter() + .chain(global_scope.allows().iter().map(|e| e.path.clone())) + .chain(command_scope.allows().iter().map(|e| e.path.clone())) + .collect(), + deny: app + .fs_scope() + .denied + .lock() + .unwrap() + .clone() + .into_iter() + .chain(global_scope.denies().iter().map(|e| e.path.clone())) + .chain(command_scope.denies().iter().map(|e| e.path.clone())) + .collect(), + require_literal_leading_dot: None, + }, + )?; + + if scope.is_allowed(&path) { + Ok(path) + } else { + Err(CommandError::Plugin(Error::PathForbidden(path))) + } +} + +#[inline] +fn file_url_to_safe_pathbuf(path: SafePathBuf) -> CommandResult { + if path.as_ref().starts_with("file:") { + let url = url::Url::parse(&path.display().to_string())? + .to_file_path() + .map_err(|_| "failed to get path from `file:` url")?; + SafePathBuf::new(url).map_err(Into::into) + } else { + Ok(path) + } +} + +struct StdFileResource(Mutex); + +impl StdFileResource { + fn new(file: File) -> Self { + Self(Mutex::new(file)) + } + + fn with_lock R>(&self, mut f: F) -> R { + let file = self.0.lock().unwrap(); + f(&file) + } +} + +impl Resource for StdFileResource {} + +struct StdLinesResource(Mutex>>); + +impl StdLinesResource { + fn new(lines: Lines>) -> Self { + Self(Mutex::new(lines)) + } + + fn with_lock>) -> R>(&self, mut f: F) -> R { + let mut lines = self.0.lock().unwrap(); + f(&mut lines) + } +} + +impl Resource for StdLinesResource {} + +// taken from deno source code: https://github.com/denoland/deno/blob/ffffa2f7c44bd26aec5ae1957e0534487d099f48/runtime/ops/fs.rs#L913 +#[inline] +fn to_msec(maybe_time: std::result::Result) -> Option { + 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, + } +} + +// taken from deno source code: https://github.com/denoland/deno/blob/ffffa2f7c44bd26aec5ae1957e0534487d099f48/runtime/ops/fs.rs#L926 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileInfo { + is_file: bool, + is_directory: bool, + is_symlink: bool, + size: u64, + // In milliseconds, like JavaScript. Available on both Unix or Windows. + mtime: Option, + atime: Option, + birthtime: Option, + readonly: bool, + // Following are only valid under Windows. + file_attribues: Option, + // Following are only valid under Unix. + dev: Option, + ino: Option, + mode: Option, + nlink: Option, + uid: Option, + gid: Option, + rdev: Option, + blksize: Option, + blocks: Option, +} + +// taken from deno source code: https://github.com/denoland/deno/blob/ffffa2f7c44bd26aec5ae1957e0534487d099f48/runtime/ops/fs.rs#L950 +#[inline(always)] +fn get_stat(metadata: std::fs::Metadata) -> FileInfo { + // Unix stat member (number types only). 0 if not on unix. + 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(), + // In milliseconds, like JavaScript. Available on both Unix or Windows. + mtime: to_msec(metadata.modified()), + atime: to_msec(metadata.accessed()), + birthtime: to_msec(metadata.created()), + readonly: metadata.permissions().readonly(), + // Following are only valid under Windows. + #[cfg(windows)] + file_attribues: Some(metadata.file_attributes()), + #[cfg(not(windows))] + file_attribues: None, + // Following are only valid under Unix. + 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), + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..d8a1971 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,18 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct Config { + /// Whether or not paths that contain components that start with a `.` + /// will require that `.` appears literally in the pattern; `*`, `?`, `**`, + /// or `[...]` will not match. This is useful because such files are + /// conventionally considered hidden on Unix systems and it might be + /// desirable to skip them when listing files. + /// + /// Defaults to `true` on Unix systems and `false` on Windows + // dotfiles are not supposed to be exposed by default on unix + pub require_literal_leading_dot: Option, +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..2d12b5e --- /dev/null +++ b/src/error.rs @@ -0,0 +1,35 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::path::PathBuf; + +use serde::{Serialize, Serializer}; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error(transparent)] + Json(#[from] serde_json::Error), + #[error(transparent)] + Tauri(#[from] tauri::Error), + #[error(transparent)] + Io(#[from] std::io::Error), + #[error("forbidden path: {0}")] + PathForbidden(PathBuf), + /// Invalid glob pattern. + #[error("invalid glob pattern: {0}")] + GlobPattern(#[from] glob::PatternError), + /// Watcher error. + #[cfg(feature = "watch")] + #[error(transparent)] + Watch(#[from] notify::Error), +} + +impl Serialize for Error { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_str(self.to_string().as_ref()) + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..2041936 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,128 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +//! [![](https://github.com/tauri-apps/plugins-workspace/raw/v2/plugins/fs/banner.png)](https://github.com/tauri-apps/plugins-workspace/tree/v2/plugins/fs) +//! +//! Access the file system. + +#![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 tauri::{ + ipc::ScopeObject, + plugin::{Builder as PluginBuilder, TauriPlugin}, + utils::acl::Value, + AppHandle, DragDropEvent, Manager, RunEvent, Runtime, WindowEvent, +}; + +mod commands; +mod config; +mod error; +mod scope; +#[cfg(feature = "watch")] +mod watcher; + +pub use error::Error; +pub use scope::{Event as ScopeEvent, Scope}; + +type Result = std::result::Result; + +// implement ScopeObject here instead of in the scope module because it is also used on the build script +// and we don't want to add tauri as a build dependency +impl ScopeObject for scope::Entry { + type Error = Error; + fn deserialize( + app: &AppHandle, + raw: Value, + ) -> std::result::Result { + let entry = serde_json::from_value(raw.into()).map(|raw| { + let path = match raw { + scope::EntryRaw::Value(path) => path, + scope::EntryRaw::Object { path } => path, + }; + Self { path } + })?; + + Ok(Self { + path: app.path().parse(entry.path)?, + }) + } +} + +pub trait FsExt { + fn fs_scope(&self) -> &Scope; + fn try_fs_scope(&self) -> Option<&Scope>; +} + +impl> FsExt for T { + fn fs_scope(&self) -> &Scope { + self.state::().inner() + } + + fn try_fs_scope(&self) -> Option<&Scope> { + self.try_state::().map(|s| s.inner()) + } +} + +pub fn init() -> TauriPlugin> { + PluginBuilder::>::new("fs") + .invoke_handler(tauri::generate_handler![ + commands::create, + commands::open, + commands::copy_file, + commands::close, + commands::mkdir, + commands::read_dir, + commands::read, + commands::read_file, + commands::read_text_file, + commands::read_text_file_lines, + commands::read_text_file_lines_next, + commands::remove, + commands::rename, + commands::seek, + commands::stat, + commands::lstat, + commands::fstat, + commands::truncate, + commands::ftruncate, + commands::write, + commands::write_file, + commands::write_text_file, + commands::exists, + #[cfg(feature = "watch")] + watcher::watch, + #[cfg(feature = "watch")] + watcher::unwatch + ]) + .setup(|app, api| { + let mut scope = Scope::default(); + scope.require_literal_leading_dot = api + .config() + .as_ref() + .and_then(|c| c.require_literal_leading_dot); + app.manage(scope); + Ok(()) + }) + .on_event(|app, event| { + if let RunEvent::WindowEvent { + label: _, + event: WindowEvent::DragDrop(DragDropEvent::Dropped { paths, position: _ }), + .. + } = event + { + let scope = app.fs_scope(); + for path in paths { + if path.is_file() { + scope.allow_file(path); + } else { + scope.allow_directory(path, true); + } + } + } + }) + .build() +} diff --git a/src/scope.rs b/src/scope.rs new file mode 100644 index 0000000..29852a0 --- /dev/null +++ b/src/scope.rs @@ -0,0 +1,128 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicU32, Ordering}, + Mutex, + }, +}; + +use serde::Deserialize; + +#[doc(hidden)] +#[derive(Deserialize)] +#[serde(untagged)] +pub enum EntryRaw { + Value(PathBuf), + Object { path: PathBuf }, +} + +#[derive(Debug)] +pub struct Entry { + pub path: PathBuf, +} + +pub type EventId = u32; +type EventListener = Box; + +/// Scope change event. +#[derive(Debug, Clone)] +pub enum Event { + /// A path has been allowed. + PathAllowed(PathBuf), + /// A path has been forbidden. + PathForbidden(PathBuf), +} + +#[derive(Default)] +pub struct Scope { + pub(crate) allowed: Mutex>, + pub(crate) denied: Mutex>, + event_listeners: Mutex>, + next_event_id: AtomicU32, + pub(crate) require_literal_leading_dot: Option, +} + +impl Scope { + /// Extend the allowed patterns with the given directory. + /// + /// After this function has been called, the frontend will be able to use the Tauri API to read + /// the directory and all of its files. If `recursive` is `true`, subdirectories will be accessible too. + pub fn allow_directory>(&self, path: P, recursive: bool) { + let path = path.as_ref(); + + let mut allowed = self.allowed.lock().unwrap(); + allowed.push(path.to_path_buf()); + allowed.push(path.join(if recursive { "**" } else { "*" })); + + self.emit(Event::PathAllowed(path.to_path_buf())); + } + + /// Extend the allowed patterns with the given file path. + /// + /// After this function has been called, the frontend will be able to use the Tauri API to read the contents of this file. + pub fn allow_file>(&self, path: P) { + let path = path.as_ref(); + + self.allowed.lock().unwrap().push(path.to_path_buf()); + + self.emit(Event::PathAllowed(path.to_path_buf())); + } + + /// Set the given directory path to be forbidden by this scope. + /// + /// **Note:** this takes precedence over allowed paths, so its access gets denied **always**. + pub fn forbid_directory>(&self, path: P, recursive: bool) { + let path = path.as_ref(); + + let mut denied = self.denied.lock().unwrap(); + denied.push(path.to_path_buf()); + denied.push(path.join(if recursive { "**" } else { "*" })); + + self.emit(Event::PathForbidden(path.to_path_buf())); + } + + /// Set the given file path to be forbidden by this scope. + /// + /// **Note:** this takes precedence over allowed paths, so its access gets denied **always**. + pub fn forbid_file>(&self, path: P) { + let path = path.as_ref(); + + self.denied.lock().unwrap().push(path.to_path_buf()); + + self.emit(Event::PathForbidden(path.to_path_buf())); + } + + /// List of allowed paths. + pub fn allowed(&self) -> Vec { + self.allowed.lock().unwrap().clone() + } + + /// List of forbidden paths. + pub fn forbidden(&self) -> Vec { + self.denied.lock().unwrap().clone() + } + + fn next_event_id(&self) -> u32 { + self.next_event_id.fetch_add(1, Ordering::Relaxed) + } + + fn emit(&self, event: Event) { + let listeners = self.event_listeners.lock().unwrap(); + let handlers = listeners.values(); + for listener in handlers { + listener(&event); + } + } + + /// Listen to an event on this scope. + pub fn listen(&self, f: F) -> EventId { + let id = self.next_event_id(); + self.event_listeners.lock().unwrap().insert(id, Box::new(f)); + id + } +} diff --git a/src/watcher.rs b/src/watcher.rs new file mode 100644 index 0000000..0795aa4 --- /dev/null +++ b/src/watcher.rs @@ -0,0 +1,157 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher}; +use notify_debouncer_full::{new_debouncer, DebounceEventResult, Debouncer, FileIdMap}; +use serde::Deserialize; +use tauri::{ + ipc::{Channel, CommandScope, GlobalScope}, + path::{BaseDirectory, SafePathBuf}, + AppHandle, Manager, Resource, ResourceId, Runtime, +}; + +use std::{ + path::PathBuf, + sync::{ + mpsc::{channel, Receiver}, + Mutex, + }, + thread::spawn, + time::Duration, +}; + +use crate::{ + commands::{resolve_path, CommandResult}, + scope::Entry, +}; + +struct InnerWatcher { + pub kind: WatcherKind, + paths: Vec, +} + +pub struct WatcherResource(Mutex); +impl WatcherResource { + fn new(kind: WatcherKind, paths: Vec) -> Self { + Self(Mutex::new(InnerWatcher { kind, paths })) + } + + fn with_lock R>(&self, mut f: F) -> R { + let mut watcher = self.0.lock().unwrap(); + f(&mut watcher) + } +} + +impl Resource for WatcherResource {} + +enum WatcherKind { + Debouncer(Debouncer), + Watcher(RecommendedWatcher), +} + +fn watch_raw(on_event: Channel, rx: Receiver>) { + spawn(move || { + while let Ok(event) = rx.recv() { + if let Ok(event) = event { + // TODO: Should errors be emitted too? + let _ = on_event.send(&event); + } + } + }); +} + +fn watch_debounced(on_event: Channel, rx: Receiver) { + spawn(move || { + while let Ok(Ok(events)) = rx.recv() { + for event in events { + // TODO: Should errors be emitted too? + let _ = on_event.send(&event.event); + } + } + }); +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WatchOptions { + base_dir: Option, + recursive: bool, + delay_ms: Option, +} + +#[tauri::command] +pub async fn watch( + app: AppHandle, + paths: Vec, + options: WatchOptions, + on_event: Channel, + global_scope: GlobalScope, + command_scope: CommandScope, +) -> CommandResult { + let mut resolved_paths = Vec::with_capacity(paths.capacity()); + for path in paths { + resolved_paths.push(resolve_path( + &app, + &global_scope, + &command_scope, + path, + options.base_dir, + )?); + } + + let mode = if options.recursive { + RecursiveMode::Recursive + } else { + RecursiveMode::NonRecursive + }; + + let kind = if let Some(delay) = options.delay_ms { + let (tx, rx) = channel(); + let mut debouncer = new_debouncer(Duration::from_millis(delay), None, tx)?; + for path in &resolved_paths { + debouncer.watcher().watch(path.as_ref(), mode)?; + } + watch_debounced(on_event, rx); + WatcherKind::Debouncer(debouncer) + } else { + let (tx, rx) = channel(); + let mut watcher = RecommendedWatcher::new(tx, Config::default())?; + for path in &resolved_paths { + watcher.watch(path.as_ref(), mode)?; + } + watch_raw(on_event, rx); + WatcherKind::Watcher(watcher) + }; + + let rid = app + .resources_table() + .add(WatcherResource::new(kind, resolved_paths)); + + Ok(rid) +} + +#[tauri::command] +pub async fn unwatch(app: AppHandle, rid: ResourceId) -> CommandResult<()> { + let watcher = app.resources_table().take::(rid)?; + WatcherResource::with_lock(&watcher, |watcher| { + match &mut watcher.kind { + WatcherKind::Debouncer(ref mut debouncer) => { + for path in &watcher.paths { + debouncer.watcher().unwatch(path.as_ref()).map_err(|e| { + format!("failed to unwatch path: {} with error: {e}", path.display()) + })?; + } + } + WatcherKind::Watcher(ref mut w) => { + for path in &watcher.paths { + w.unwatch(path.as_ref()).map_err(|e| { + format!("failed to unwatch path: {} with error: {e}", path.display()) + })?; + } + } + } + + Ok(()) + }) +}