1#![allow(unexpected_cfgs)]
25
26use std::fmt;
27use std::path::{Path, PathBuf};
28use std::sync::{Arc, OnceLock};
29
30use serde::{Deserialize, Serialize, Serializer};
31use tauri::{
32 plugin::{Builder as PluginBuilder, TauriPlugin},
33 Emitter, Manager, Runtime, WebviewWindow,
34};
35
36#[cfg(target_os = "macos")]
37mod macos;
38
39pub const EVENT_DROPPED: &str = "dragout://dropped";
41pub const EVENT_ENDED: &str = "dragout://ended";
43pub const EVENT_ERROR: &str = "dragout://error";
45
46pub type PromiseWriter = Arc<dyn Fn(&Path) -> Result<(), String> + Send + Sync>;
48
49#[derive(Clone, Deserialize)]
55#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
56pub enum DragItem {
57 File { path: PathBuf },
60 Copy {
63 source: PathBuf,
64 #[serde(default)]
65 file_name: Option<String>,
66 },
67 Data {
69 file_name: String,
70 #[serde(deserialize_with = "de_base64")]
71 data: Vec<u8>,
72 },
73 #[serde(skip)]
76 Promise {
77 file_name: String,
78 write: PromiseWriter,
79 },
80}
81
82impl DragItem {
83 pub fn file(path: impl Into<PathBuf>) -> Self {
85 DragItem::File { path: path.into() }
86 }
87 pub fn copy(source: impl Into<PathBuf>, file_name: Option<String>) -> Self {
89 DragItem::Copy { source: source.into(), file_name }
90 }
91 pub fn data(file_name: impl Into<String>, data: impl Into<Vec<u8>>) -> Self {
93 DragItem::Data { file_name: file_name.into(), data: data.into() }
94 }
95 pub fn promise<F>(file_name: impl Into<String>, write: F) -> Self
97 where
98 F: Fn(&Path) -> Result<(), String> + Send + Sync + 'static,
99 {
100 DragItem::Promise { file_name: file_name.into(), write: Arc::new(write) }
101 }
102
103 pub fn display_name(&self) -> String {
105 match self {
106 DragItem::File { path } => file_name_of(path),
107 DragItem::Copy { source, file_name } => {
108 file_name.clone().unwrap_or_else(|| file_name_of(source))
109 }
110 DragItem::Data { file_name, .. } | DragItem::Promise { file_name, .. } => {
111 file_name.clone()
112 }
113 }
114 }
115}
116
117impl fmt::Debug for DragItem {
118 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119 match self {
120 DragItem::File { path } => f.debug_struct("File").field("path", path).finish(),
121 DragItem::Copy { source, file_name } => f
122 .debug_struct("Copy")
123 .field("source", source)
124 .field("file_name", file_name)
125 .finish(),
126 DragItem::Data { file_name, data } => f
127 .debug_struct("Data")
128 .field("file_name", file_name)
129 .field("len", &data.len())
130 .finish(),
131 DragItem::Promise { file_name, .. } => {
132 f.debug_struct("Promise").field("file_name", file_name).finish_non_exhaustive()
133 }
134 }
135 }
136}
137
138#[derive(Debug, Clone, Default, Deserialize)]
140#[serde(rename_all = "camelCase")]
141pub struct DragOptions {
142 #[serde(default)]
144 pub icon: Option<PathBuf>,
145 #[serde(default, deserialize_with = "de_base64_opt")]
148 pub icon_data: Option<Vec<u8>>,
149 #[serde(default)]
151 pub icon_size: Option<f64>,
152 #[serde(default)]
155 pub allow_self_drop: bool,
156}
157
158#[derive(Debug, Clone, Copy, Serialize)]
160pub struct DragSession {
161 pub id: u64,
162}
163
164#[derive(Debug, Clone)]
166pub enum Error {
167 Unsupported,
169 Empty,
171 InvalidItem(String),
173 NoMouse,
176 Platform(String),
178}
179
180impl fmt::Display for Error {
181 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
182 match self {
183 Error::Unsupported => f.write_str("native drag-out is not supported on this platform"),
184 Error::Empty => f.write_str("no items to drag"),
185 Error::InvalidItem(m) => write!(f, "invalid drag item: {m}"),
186 Error::NoMouse => f.write_str(
187 "no mouse button is pressed; call startDrag from a mousedown/pointermove handler while the button is held",
188 ),
189 Error::Platform(m) => write!(f, "drag failed: {m}"),
190 }
191 }
192}
193
194impl std::error::Error for Error {}
195
196impl Serialize for Error {
197 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
198 s.serialize_str(&self.to_string())
199 }
200}
201
202type PathFilter = Box<dyn Fn(&Path) -> bool + Send + Sync>;
203type EmitFn = Box<dyn Fn(&str, serde_json::Value) + Send + Sync>;
204
205static PATH_FILTER: OnceLock<PathFilter> = OnceLock::new();
206static EMITTER: OnceLock<EmitFn> = OnceLock::new();
207
208#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
209pub(crate) fn emit(event: &str, payload: serde_json::Value) {
210 if let Some(e) = EMITTER.get() {
211 e(event, payload);
212 }
213}
214
215#[derive(Default)]
217pub struct Builder {
218 path_filter: Option<PathFilter>,
219}
220
221impl Builder {
222 pub fn new() -> Self {
223 Self::default()
224 }
225
226 pub fn path_filter<F>(mut self, filter: F) -> Self
229 where
230 F: Fn(&Path) -> bool + Send + Sync + 'static,
231 {
232 self.path_filter = Some(Box::new(filter));
233 self
234 }
235
236 pub fn build<R: Runtime>(self) -> TauriPlugin<R> {
237 let mut filter = self.path_filter;
238 PluginBuilder::new("dragout")
239 .invoke_handler(tauri::generate_handler![commands::start_drag, commands::is_supported])
240 .setup(move |app, _api| {
241 if let Some(f) = filter.take() {
242 let _ = PATH_FILTER.set(f);
243 }
244 let handle = app.app_handle().clone();
245 let _ = EMITTER.set(Box::new(move |event, payload| {
246 let _ = handle.emit(event, payload);
247 }));
248 Ok(())
249 })
250 .build()
251 }
252}
253
254pub fn init<R: Runtime>() -> TauriPlugin<R> {
256 Builder::new().build()
257}
258
259pub fn is_supported() -> bool {
261 cfg!(target_os = "macos")
262}
263
264pub fn start_drag<R: Runtime>(
269 window: &WebviewWindow<R>,
270 items: Vec<DragItem>,
271 options: DragOptions,
272) -> Result<DragSession, Error> {
273 validate(&items, false)?;
274 platform_start(window, items, options)
275}
276
277fn validate(items: &[DragItem], from_js: bool) -> Result<(), Error> {
278 if items.is_empty() {
279 return Err(Error::Empty);
280 }
281 for item in items {
282 match item {
283 DragItem::File { path } => check_path(path, from_js)?,
284 DragItem::Copy { source, file_name } => {
285 check_path(source, from_js)?;
286 if let Some(n) = file_name {
287 check_name(n)?;
288 }
289 }
290 DragItem::Data { file_name, .. } | DragItem::Promise { file_name, .. } => {
291 check_name(file_name)?
292 }
293 }
294 }
295 Ok(())
296}
297
298fn check_path(path: &Path, from_js: bool) -> Result<(), Error> {
299 if !path.is_absolute() {
300 return Err(Error::InvalidItem(format!("path must be absolute: {}", path.display())));
301 }
302 if from_js {
303 if let Some(filter) = PATH_FILTER.get() {
304 if !filter(path) {
305 return Err(Error::InvalidItem(format!("path not allowed: {}", path.display())));
306 }
307 }
308 }
309 if !path.exists() {
310 return Err(Error::InvalidItem(format!("no such file: {}", path.display())));
311 }
312 Ok(())
313}
314
315fn check_name(name: &str) -> Result<(), Error> {
316 let bad = name.is_empty()
317 || name == "."
318 || name == ".."
319 || name.contains('/')
320 || name.contains('\\')
321 || name.contains(':')
322 || name.contains('\0');
323 if bad {
324 Err(Error::InvalidItem(format!("bad file name: {name:?}")))
325 } else {
326 Ok(())
327 }
328}
329
330fn file_name_of(path: &Path) -> String {
331 path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
332}
333
334#[cfg(target_os = "macos")]
335fn platform_start<R: Runtime>(
336 window: &WebviewWindow<R>,
337 items: Vec<DragItem>,
338 options: DragOptions,
339) -> Result<DragSession, Error> {
340 let ns_window = window.ns_window().map_err(|e| Error::Platform(e.to_string()))? as usize;
341 if macos::is_main_thread() {
342 return macos::start(ns_window, items, options);
343 }
344 let (tx, rx) = std::sync::mpsc::channel();
345 window
346 .run_on_main_thread(move || {
347 let _ = tx.send(macos::start(ns_window, items, options));
348 })
349 .map_err(|e| Error::Platform(e.to_string()))?;
350 rx.recv().map_err(|e| Error::Platform(e.to_string()))?
351}
352
353#[cfg(not(target_os = "macos"))]
354fn platform_start<R: Runtime>(
355 _window: &WebviewWindow<R>,
356 _items: Vec<DragItem>,
357 _options: DragOptions,
358) -> Result<DragSession, Error> {
359 Err(Error::Unsupported)
360}
361
362mod commands {
363 use super::*;
364
365 #[tauri::command]
368 pub fn start_drag<R: Runtime>(
369 window: WebviewWindow<R>,
370 items: Vec<DragItem>,
371 options: Option<DragOptions>,
372 ) -> Result<DragSession, Error> {
373 validate(&items, true)?;
374 platform_start(&window, items, options.unwrap_or_default())
375 }
376
377 #[tauri::command]
378 pub fn is_supported() -> bool {
379 super::is_supported()
380 }
381}
382
383fn decode_base64<E: serde::de::Error>(s: &str) -> Result<Vec<u8>, E> {
384 use base64::Engine;
385 let s = s.split_once(";base64,").map(|(_, b)| b).unwrap_or(s);
387 base64::engine::general_purpose::STANDARD.decode(s.trim().as_bytes()).map_err(E::custom)
388}
389
390fn de_base64<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> {
391 let s = String::deserialize(d)?;
392 decode_base64(&s)
393}
394
395fn de_base64_opt<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<Vec<u8>>, D::Error> {
396 match Option::<String>::deserialize(d)? {
397 Some(s) => decode_base64(&s).map(Some),
398 None => Ok(None),
399 }
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 #[test]
407 fn parses_js_items() {
408 let v = serde_json::json!([
409 {"type": "file", "path": "/tmp/a.pdf"},
410 {"type": "copy", "source": "/tmp/b.docx", "fileName": "c.docx"},
411 {"type": "data", "fileName": "d.txt", "data": "aGVsbG8="}
412 ]);
413 let items: Vec<DragItem> = serde_json::from_value(v).unwrap();
414 assert!(matches!(&items[0], DragItem::File { path } if path == Path::new("/tmp/a.pdf")));
415 assert_eq!(items[1].display_name(), "c.docx");
416 assert!(matches!(&items[2], DragItem::Data { data, .. } if data == b"hello"));
417 }
418
419 #[test]
420 fn parses_options() {
421 let v = serde_json::json!({"iconData": "data:image/png;base64,aGk=", "iconSize": 48, "allowSelfDrop": true});
422 let o: DragOptions = serde_json::from_value(v).unwrap();
423 assert_eq!(o.icon_data.as_deref(), Some(&b"hi"[..]));
424 assert_eq!(o.icon_size, Some(48.0));
425 assert!(o.allow_self_drop);
426 let o: DragOptions = serde_json::from_value(serde_json::json!({})).unwrap();
427 assert!(o.icon_data.is_none() && !o.allow_self_drop);
428 }
429
430 #[test]
431 fn rejects_promise_from_js_and_bad_items() {
432 let v = serde_json::json!([{"type": "promise", "fileName": "x"}]);
433 assert!(serde_json::from_value::<Vec<DragItem>>(v).is_err());
434 assert!(check_name("../evil").is_err());
435 assert!(check_name("a/b").is_err());
436 assert!(check_name("ok.pdf").is_ok());
437 assert!(matches!(validate(&[], true), Err(Error::Empty)));
438 assert!(validate(&[DragItem::file("relative.txt")], true).is_err());
439 assert!(validate(&[DragItem::file("/definitely/missing/file")], true).is_err());
440 assert!(validate(&[DragItem::data("ok.txt", b"x".to_vec())], true).is_ok());
441 }
442}