#![allow(unexpected_cfgs)]
use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock};
use serde::{Deserialize, Serialize, Serializer};
use tauri::{
plugin::{Builder as PluginBuilder, TauriPlugin},
Emitter, Manager, Runtime, WebviewWindow,
};
#[cfg(target_os = "macos")]
mod macos;
pub const EVENT_DROPPED: &str = "dragout://dropped";
pub const EVENT_ENDED: &str = "dragout://ended";
pub const EVENT_ERROR: &str = "dragout://error";
pub type PromiseWriter = Arc<dyn Fn(&Path) -> Result<(), String> + Send + Sync>;
#[derive(Clone, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum DragItem {
File { path: PathBuf },
Copy {
source: PathBuf,
#[serde(default)]
file_name: Option<String>,
},
Data {
file_name: String,
#[serde(deserialize_with = "de_base64")]
data: Vec<u8>,
},
#[serde(skip)]
Promise {
file_name: String,
write: PromiseWriter,
},
}
impl DragItem {
pub fn file(path: impl Into<PathBuf>) -> Self {
DragItem::File { path: path.into() }
}
pub fn copy(source: impl Into<PathBuf>, file_name: Option<String>) -> Self {
DragItem::Copy { source: source.into(), file_name }
}
pub fn data(file_name: impl Into<String>, data: impl Into<Vec<u8>>) -> Self {
DragItem::Data { file_name: file_name.into(), data: data.into() }
}
pub fn promise<F>(file_name: impl Into<String>, write: F) -> Self
where
F: Fn(&Path) -> Result<(), String> + Send + Sync + 'static,
{
DragItem::Promise { file_name: file_name.into(), write: Arc::new(write) }
}
pub fn display_name(&self) -> String {
match self {
DragItem::File { path } => file_name_of(path),
DragItem::Copy { source, file_name } => {
file_name.clone().unwrap_or_else(|| file_name_of(source))
}
DragItem::Data { file_name, .. } | DragItem::Promise { file_name, .. } => {
file_name.clone()
}
}
}
}
impl fmt::Debug for DragItem {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DragItem::File { path } => f.debug_struct("File").field("path", path).finish(),
DragItem::Copy { source, file_name } => f
.debug_struct("Copy")
.field("source", source)
.field("file_name", file_name)
.finish(),
DragItem::Data { file_name, data } => f
.debug_struct("Data")
.field("file_name", file_name)
.field("len", &data.len())
.finish(),
DragItem::Promise { file_name, .. } => {
f.debug_struct("Promise").field("file_name", file_name).finish_non_exhaustive()
}
}
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DragOptions {
#[serde(default)]
pub icon: Option<PathBuf>,
#[serde(default, deserialize_with = "de_base64_opt")]
pub icon_data: Option<Vec<u8>>,
#[serde(default)]
pub icon_size: Option<f64>,
#[serde(default)]
pub allow_self_drop: bool,
}
#[derive(Debug, Clone, Copy, Serialize)]
pub struct DragSession {
pub id: u64,
}
#[derive(Debug, Clone)]
pub enum Error {
Unsupported,
Empty,
InvalidItem(String),
NoMouse,
Platform(String),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::Unsupported => f.write_str("native drag-out is not supported on this platform"),
Error::Empty => f.write_str("no items to drag"),
Error::InvalidItem(m) => write!(f, "invalid drag item: {m}"),
Error::NoMouse => f.write_str(
"no mouse button is pressed; call startDrag from a mousedown/pointermove handler while the button is held",
),
Error::Platform(m) => write!(f, "drag failed: {m}"),
}
}
}
impl std::error::Error for Error {}
impl Serialize for Error {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.to_string())
}
}
type PathFilter = Box<dyn Fn(&Path) -> bool + Send + Sync>;
type EmitFn = Box<dyn Fn(&str, serde_json::Value) + Send + Sync>;
static PATH_FILTER: OnceLock<PathFilter> = OnceLock::new();
static EMITTER: OnceLock<EmitFn> = OnceLock::new();
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub(crate) fn emit(event: &str, payload: serde_json::Value) {
if let Some(e) = EMITTER.get() {
e(event, payload);
}
}
#[derive(Default)]
pub struct Builder {
path_filter: Option<PathFilter>,
}
impl Builder {
pub fn new() -> Self {
Self::default()
}
pub fn path_filter<F>(mut self, filter: F) -> Self
where
F: Fn(&Path) -> bool + Send + Sync + 'static,
{
self.path_filter = Some(Box::new(filter));
self
}
pub fn build<R: Runtime>(self) -> TauriPlugin<R> {
let mut filter = self.path_filter;
PluginBuilder::new("dragout")
.invoke_handler(tauri::generate_handler![commands::start_drag, commands::is_supported])
.setup(move |app, _api| {
if let Some(f) = filter.take() {
let _ = PATH_FILTER.set(f);
}
let handle = app.app_handle().clone();
let _ = EMITTER.set(Box::new(move |event, payload| {
let _ = handle.emit(event, payload);
}));
Ok(())
})
.build()
}
}
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new().build()
}
pub fn is_supported() -> bool {
cfg!(target_os = "macos")
}
pub fn start_drag<R: Runtime>(
window: &WebviewWindow<R>,
items: Vec<DragItem>,
options: DragOptions,
) -> Result<DragSession, Error> {
validate(&items, false)?;
platform_start(window, items, options)
}
fn validate(items: &[DragItem], from_js: bool) -> Result<(), Error> {
if items.is_empty() {
return Err(Error::Empty);
}
for item in items {
match item {
DragItem::File { path } => check_path(path, from_js)?,
DragItem::Copy { source, file_name } => {
check_path(source, from_js)?;
if let Some(n) = file_name {
check_name(n)?;
}
}
DragItem::Data { file_name, .. } | DragItem::Promise { file_name, .. } => {
check_name(file_name)?
}
}
}
Ok(())
}
fn check_path(path: &Path, from_js: bool) -> Result<(), Error> {
if !path.is_absolute() {
return Err(Error::InvalidItem(format!("path must be absolute: {}", path.display())));
}
if from_js {
if let Some(filter) = PATH_FILTER.get() {
if !filter(path) {
return Err(Error::InvalidItem(format!("path not allowed: {}", path.display())));
}
}
}
if !path.exists() {
return Err(Error::InvalidItem(format!("no such file: {}", path.display())));
}
Ok(())
}
fn check_name(name: &str) -> Result<(), Error> {
let bad = name.is_empty()
|| name == "."
|| name == ".."
|| name.contains('/')
|| name.contains('\\')
|| name.contains(':')
|| name.contains('\0');
if bad {
Err(Error::InvalidItem(format!("bad file name: {name:?}")))
} else {
Ok(())
}
}
fn file_name_of(path: &Path) -> String {
path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
}
#[cfg(target_os = "macos")]
fn platform_start<R: Runtime>(
window: &WebviewWindow<R>,
items: Vec<DragItem>,
options: DragOptions,
) -> Result<DragSession, Error> {
let ns_window = window.ns_window().map_err(|e| Error::Platform(e.to_string()))? as usize;
if macos::is_main_thread() {
return macos::start(ns_window, items, options);
}
let (tx, rx) = std::sync::mpsc::channel();
window
.run_on_main_thread(move || {
let _ = tx.send(macos::start(ns_window, items, options));
})
.map_err(|e| Error::Platform(e.to_string()))?;
rx.recv().map_err(|e| Error::Platform(e.to_string()))?
}
#[cfg(not(target_os = "macos"))]
fn platform_start<R: Runtime>(
_window: &WebviewWindow<R>,
_items: Vec<DragItem>,
_options: DragOptions,
) -> Result<DragSession, Error> {
Err(Error::Unsupported)
}
mod commands {
use super::*;
#[tauri::command]
pub fn start_drag<R: Runtime>(
window: WebviewWindow<R>,
items: Vec<DragItem>,
options: Option<DragOptions>,
) -> Result<DragSession, Error> {
validate(&items, true)?;
platform_start(&window, items, options.unwrap_or_default())
}
#[tauri::command]
pub fn is_supported() -> bool {
super::is_supported()
}
}
fn decode_base64<E: serde::de::Error>(s: &str) -> Result<Vec<u8>, E> {
use base64::Engine;
let s = s.split_once(";base64,").map(|(_, b)| b).unwrap_or(s);
base64::engine::general_purpose::STANDARD.decode(s.trim().as_bytes()).map_err(E::custom)
}
fn de_base64<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> {
let s = String::deserialize(d)?;
decode_base64(&s)
}
fn de_base64_opt<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<Vec<u8>>, D::Error> {
match Option::<String>::deserialize(d)? {
Some(s) => decode_base64(&s).map(Some),
None => Ok(None),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_js_items() {
let v = serde_json::json!([
{"type": "file", "path": "/tmp/a.pdf"},
{"type": "copy", "source": "/tmp/b.docx", "fileName": "c.docx"},
{"type": "data", "fileName": "d.txt", "data": "aGVsbG8="}
]);
let items: Vec<DragItem> = serde_json::from_value(v).unwrap();
assert!(matches!(&items[0], DragItem::File { path } if path == Path::new("/tmp/a.pdf")));
assert_eq!(items[1].display_name(), "c.docx");
assert!(matches!(&items[2], DragItem::Data { data, .. } if data == b"hello"));
}
#[test]
fn parses_options() {
let v = serde_json::json!({"iconData": "data:image/png;base64,aGk=", "iconSize": 48, "allowSelfDrop": true});
let o: DragOptions = serde_json::from_value(v).unwrap();
assert_eq!(o.icon_data.as_deref(), Some(&b"hi"[..]));
assert_eq!(o.icon_size, Some(48.0));
assert!(o.allow_self_drop);
let o: DragOptions = serde_json::from_value(serde_json::json!({})).unwrap();
assert!(o.icon_data.is_none() && !o.allow_self_drop);
}
#[test]
fn rejects_promise_from_js_and_bad_items() {
let v = serde_json::json!([{"type": "promise", "fileName": "x"}]);
assert!(serde_json::from_value::<Vec<DragItem>>(v).is_err());
assert!(check_name("../evil").is_err());
assert!(check_name("a/b").is_err());
assert!(check_name("ok.pdf").is_ok());
assert!(matches!(validate(&[], true), Err(Error::Empty)));
assert!(validate(&[DragItem::file("relative.txt")], true).is_err());
assert!(validate(&[DragItem::file("/definitely/missing/file")], true).is_err());
assert!(validate(&[DragItem::data("ok.txt", b"x".to_vec())], true).is_ok());
}
}