Skip to main content

tauri_plugin_dragout/
lib.rs

1//! Native drag-out for Tauri 2.
2//!
3//! A webview cannot start an OS-level file drag on its own: an HTML `dragstart`
4//! in WKWebView only carries text, URLs or in-page data, so users cannot drag a
5//! document out of a Tauri app onto Finder, the Desktop, Mail or Slack. This
6//! plugin starts a real `NSDraggingSession` from the window under the mouse and
7//! lets you drag:
8//!
9//! * **existing files** ([`DragItem::File`]): the target receives the file itself;
10//! * **copies of files** ([`DragItem::Copy`]): copied only when dropped, optionally under another name;
11//! * **generated bytes** ([`DragItem::Data`]): written to the drop location on drop;
12//! * **lazy promises** ([`DragItem::promise`], Rust only): your closure writes the file
13//!   when (and only if) the user actually drops it, e.g. export, decrypt or download on demand.
14//!
15//! Supported on macOS. On other platforms the plugin compiles, [`is_supported`]
16//! returns `false` and [`start_drag`] returns [`Error::Unsupported`].
17//!
18//! ```no_run
19//! tauri::Builder::default()
20//!     .plugin(tauri_plugin_dragout::init())
21//!     # ;
22//! ```
23
24#![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
39/// Event emitted when a promised item has been written at its drop location.
40pub const EVENT_DROPPED: &str = "dragout://dropped";
41/// Event emitted when a drag session ends (dropped or cancelled).
42pub const EVENT_ENDED: &str = "dragout://ended";
43/// Event emitted when writing a promised item failed.
44pub const EVENT_ERROR: &str = "dragout://error";
45
46/// Writer used by [`DragItem::Promise`]: receives the full destination path.
47pub type PromiseWriter = Arc<dyn Fn(&Path) -> Result<(), String> + Send + Sync>;
48
49/// One thing to drag.
50///
51/// From JavaScript items are tagged objects:
52/// `{ type: "file", path }`, `{ type: "copy", source, fileName? }`,
53/// `{ type: "data", fileName, data }` where `data` is base64.
54#[derive(Clone, Deserialize)]
55#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
56pub enum DragItem {
57    /// An existing file or folder. The drop target gets the original path
58    /// (Finder copies it, other apps attach or open it).
59    File { path: PathBuf },
60    /// Copy `source` to the drop location when the user drops it,
61    /// optionally under a different `file_name`. Works for files and folders.
62    Copy {
63        source: PathBuf,
64        #[serde(default)]
65        file_name: Option<String>,
66    },
67    /// Write these bytes to `file_name` at the drop location.
68    Data {
69        file_name: String,
70        #[serde(deserialize_with = "de_base64")]
71        data: Vec<u8>,
72    },
73    /// Rust only: call `write` with the destination path when the item is dropped.
74    /// Runs on a background queue, so it may block (render, decrypt, download...).
75    #[serde(skip)]
76    Promise {
77        file_name: String,
78        write: PromiseWriter,
79    },
80}
81
82impl DragItem {
83    /// An existing file or folder.
84    pub fn file(path: impl Into<PathBuf>) -> Self {
85        DragItem::File { path: path.into() }
86    }
87    /// A copy of `source`, made on drop.
88    pub fn copy(source: impl Into<PathBuf>, file_name: Option<String>) -> Self {
89        DragItem::Copy { source: source.into(), file_name }
90    }
91    /// Bytes written to `file_name` on drop.
92    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    /// A lazy file: `write` receives the destination path when the user drops it.
96    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    /// File name the drop target will see.
104    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/// Options for a drag session.
139#[derive(Debug, Clone, Default, Deserialize)]
140#[serde(rename_all = "camelCase")]
141pub struct DragOptions {
142    /// Image file used as the drag preview instead of the system file icon.
143    #[serde(default)]
144    pub icon: Option<PathBuf>,
145    /// Encoded image (PNG/JPEG) used as the drag preview; base64 from JS.
146    /// Handy for a canvas snapshot of the element being dragged.
147    #[serde(default, deserialize_with = "de_base64_opt")]
148    pub icon_data: Option<Vec<u8>>,
149    /// Preview size in points (default 64).
150    #[serde(default)]
151    pub icon_size: Option<f64>,
152    /// Allow dropping back onto this app's own windows (default `false`,
153    /// so the webview does not receive its own drag as a file drop).
154    #[serde(default)]
155    pub allow_self_drop: bool,
156}
157
158/// Handle of a started drag; matches the `session` field of the plugin events.
159#[derive(Debug, Clone, Copy, Serialize)]
160pub struct DragSession {
161    pub id: u64,
162}
163
164/// Errors returned by [`start_drag`].
165#[derive(Debug, Clone)]
166pub enum Error {
167    /// Native drag-out is not implemented on this platform.
168    Unsupported,
169    /// No items were given.
170    Empty,
171    /// An item is invalid (missing file, bad file name, path not allowed...).
172    InvalidItem(String),
173    /// No mouse button is pressed: start the drag from a `mousedown` /
174    /// `pointermove` handler while the button is held.
175    NoMouse,
176    /// The platform refused to start the drag.
177    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/// Plugin builder.
216#[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    /// Restrict which paths the **frontend** may drag as `file` / `copy` items.
227    /// Items built in Rust are not filtered. Without a filter every readable path is allowed.
228    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
254/// Plugin with default settings.
255pub fn init<R: Runtime>() -> TauriPlugin<R> {
256    Builder::new().build()
257}
258
259/// Whether native drag-out works on this platform.
260pub fn is_supported() -> bool {
261    cfg!(target_os = "macos")
262}
263
264/// Start a native drag from `window` with `items`.
265///
266/// Must be called while a mouse button is held (from a mousedown/pointermove
267/// handler). Safe to call from any thread: the work is dispatched to the main thread.
268pub 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    // A sync command runs on the main thread, which AppKit requires, and the
366    // mouse-down event that triggered the IPC call is still current there.
367    #[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    // Accept data URLs too: "data:image/png;base64,...."
386    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}