Skip to main content

ashlar_native/
open_files.rs

1//! Files and URLs the app is asked to open: from the command line, from the file manager (a
2//! double-click on a document of an associated type, a drop on the Dock icon) and from a second
3//! launch forwarded by [`crate::single_instance`]. They all arrive in one queue, [`OpenFiles`].
4//!
5//! - Command line (every platform): [`OpenFiles::push_args`] at startup.
6//! - macOS: Finder sends `odoc` (open documents) and `GURL` (open URL) Apple events, at launch
7//!   and while running, instead of passing arguments. [`OpenFiles::listen_os`] installs handlers
8//!   for them; call it on the main thread before the event loop starts, so the launch documents
9//!   are not missed. It works alongside any windowing library: it uses the Apple event manager,
10//!   not the application delegate.
11//! - Windows and Linux: the file manager starts the app with the files as arguments; a second
12//!   launch while one runs is forwarded with [`crate::single_instance`].
13//!
14//! File associations themselves are declared when the app is packaged (`CFBundleDocumentTypes`
15//! in Info.plist, registry ProgIDs, `.desktop` `MimeType=`), not at run time.
16
17use std::ffi::OsStr;
18use std::path::{Path, PathBuf};
19use std::sync::mpsc::{Receiver, Sender, channel};
20
21use crate::{NativeError, Result, Waker};
22
23/// At most this many entries are taken from one launch or event.
24pub const MAX_ITEMS: usize = 1024;
25/// Longest accepted argument or URL.
26pub const MAX_LEN: usize = 32 * 1024;
27
28/// Something to open.
29#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
30#[serde(rename_all = "camelCase")]
31pub enum Opened {
32    /// An absolute path (relative arguments are resolved against the launch's directory).
33    File(PathBuf),
34    /// A URL with a scheme other than `file:` (`myapp://open?doc=1`, `https://…`).
35    Url(String),
36}
37
38/// Whether `s` starts with a URL scheme (`scheme:`), RFC 3986: a letter, then letters, digits,
39/// `+`, `-`, `.`; at least two characters so `C:\file` stays a path.
40fn url_scheme(s: &str) -> Option<&str> {
41    let (scheme, _) = s.split_once(':')?;
42    let mut chars = scheme.chars();
43    let ok =
44        scheme.len() >= 2 && chars.next().is_some_and(|c| c.is_ascii_alphabetic()) && chars.all(|c| c.is_ascii_alphanumeric() || "+-.".contains(c));
45    ok.then_some(scheme)
46}
47
48/// A `file:` URL as a path (`file:///Users/a/My%20Doc.txt`). Only local (`file:///` or
49/// `file://localhost/`) URLs.
50pub fn file_url_to_path(url: &str) -> Option<PathBuf> {
51    let rest = url.strip_prefix("file://").or_else(|| url.strip_prefix("FILE://"))?;
52    let rest = rest.strip_prefix("localhost").unwrap_or(rest);
53    if !rest.starts_with('/') {
54        return None;
55    }
56    let bytes = rest.as_bytes();
57    let mut out = Vec::with_capacity(bytes.len());
58    let mut i = 0;
59    while let Some(&b) = bytes.get(i) {
60        if b == b'%' {
61            let hex = bytes.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok())?;
62            out.push(hex);
63            i += 3;
64        } else {
65            out.push(b);
66            i += 1;
67        }
68    }
69    let s = String::from_utf8(out).ok()?;
70    if s.contains('\0') {
71        return None;
72    }
73    // `file:///C:/x` on Windows.
74    #[cfg(windows)]
75    let s = if s.get(2..3) == Some(":") { s.get(1..).unwrap_or_default().to_string() } else { s };
76    Some(PathBuf::from(s))
77}
78
79/// Classify one string: a URL (`file:` URLs become paths) or a path resolved against `cwd`.
80pub fn classify(item: &str, cwd: &Path) -> Option<Opened> {
81    if item.is_empty() || item.len() > MAX_LEN || item.contains('\0') {
82        return None;
83    }
84    if let Some(scheme) = url_scheme(item) {
85        if scheme.eq_ignore_ascii_case("file") {
86            return file_url_to_path(item).map(Opened::File);
87        }
88        // `C:\x` has a one-letter scheme and is excluded above; `a:b` style relative paths are
89        // rare enough that a scheme wins.
90        return Some(Opened::Url(item.to_string()));
91    }
92    let p = Path::new(item);
93    Some(Opened::File(if p.is_absolute() { p.to_path_buf() } else { cwd.join(p) }))
94}
95
96/// What to open from command-line arguments (without the program name): flags (`-x`, `--x`,
97/// macOS' `-psn_…`) are skipped, everything after `--` is taken literally, relative paths are
98/// resolved against `cwd`. Non-UTF-8 arguments are taken as paths.
99pub fn from_args<I, S>(args: I, cwd: &Path) -> Vec<Opened>
100where
101    I: IntoIterator<Item = S>,
102    S: AsRef<OsStr>,
103{
104    let mut out = vec![];
105    let mut literal = false;
106    for a in args.into_iter().take(MAX_ITEMS * 4) {
107        let a = a.as_ref();
108        match a.to_str() {
109            Some("--") if !literal => literal = true,
110            Some(s) if !literal && s.starts_with('-') => {}
111            Some(s) => out.extend(classify(s, cwd)),
112            None => {
113                let p = Path::new(a);
114                out.push(Opened::File(if p.is_absolute() { p.to_path_buf() } else { cwd.join(p) }));
115            }
116        }
117        if out.len() >= MAX_ITEMS {
118            break;
119        }
120    }
121    out
122}
123
124/// A handle that queues into an [`OpenFiles`] from any thread.
125#[derive(Clone)]
126pub struct OpenSender {
127    tx: Sender<Opened>,
128    waker: Waker,
129}
130
131impl OpenSender {
132    /// Queue items and wake the app once. Returns `false` when the queue is gone.
133    pub fn send(&self, items: impl IntoIterator<Item = Opened>) -> bool {
134        let mut any = false;
135        for item in items.into_iter().take(MAX_ITEMS) {
136            if self.tx.send(item).is_err() {
137                return false;
138            }
139            any = true;
140        }
141        if any {
142            (self.waker)();
143        }
144        true
145    }
146}
147
148/// The queue of things to open.
149pub struct OpenFiles {
150    sender: OpenSender,
151    rx: Receiver<Opened>,
152}
153
154impl OpenFiles {
155    /// A queue that calls `waker` whenever something arrives from another thread or the OS.
156    pub fn new(waker: Waker) -> Self {
157        let (tx, rx) = channel();
158        Self { sender: OpenSender { tx, waker }, rx }
159    }
160
161    /// Queue what the command line names (`std::env::args_os().skip(1)`).
162    pub fn push_args<I, S>(&self, args: I, cwd: &Path)
163    where
164        I: IntoIterator<Item = S>,
165        S: AsRef<OsStr>,
166    {
167        self.sender.send(from_args(args, cwd));
168    }
169
170    pub fn push(&self, item: Opened) {
171        self.sender.send([item]);
172    }
173
174    /// A sender for other sources (the single-instance listener).
175    pub fn sender(&self) -> OpenSender {
176        self.sender.clone()
177    }
178
179    /// Everything queued since the last call, oldest first.
180    pub fn drain(&self) -> Vec<Opened> {
181        self.rx.try_iter().take(MAX_ITEMS).collect()
182    }
183
184    /// Receive what the OS sends while the app runs. macOS: installs the `odoc` / `GURL` Apple
185    /// event handlers (main thread, before the event loop starts; a later call replaces the
186    /// queue they feed). Elsewhere `Unsupported`: files arrive as arguments.
187    pub fn listen_os(&self) -> Result<()> {
188        imp::listen(self.sender.clone())
189    }
190}
191
192#[cfg(target_os = "macos")]
193#[allow(unsafe_code)]
194mod imp {
195    use std::ptr::NonNull;
196    use std::sync::{Mutex, Once, PoisonError};
197
198    use block2::RcBlock;
199    use objc2::rc::Retained;
200    use objc2::runtime::{AnyObject, NSObject};
201    use objc2::{AnyThread, MainThreadMarker, define_class, msg_send, sel};
202    use objc2_foundation::{NSAppleEventDescriptor, NSAppleEventManager, NSNotification, NSNotificationCenter, NSString};
203
204    use super::*;
205
206    const fn four(c: &[u8; 4]) -> u32 {
207        u32::from_be_bytes(*c)
208    }
209    const CORE_EVENT_CLASS: u32 = four(b"aevt");
210    const OPEN_DOCUMENTS: u32 = four(b"odoc");
211    const INTERNET_EVENT_CLASS: u32 = four(b"GURL");
212    const GET_URL: u32 = four(b"GURL");
213    const DIRECT_OBJECT: u32 = four(b"----");
214    const TYPE_FILE_URL: u32 = four(b"furl");
215
216    /// Where the handlers deliver. Replaced by each `listen`.
217    static SINK: Mutex<Option<OpenSender>> = Mutex::new(None);
218
219    fn deliver(items: Vec<Opened>) {
220        if items.is_empty() {
221            return;
222        }
223        let sink = SINK.lock().unwrap_or_else(PoisonError::into_inner).clone();
224        if let Some(s) = sink {
225            s.send(items);
226        }
227    }
228
229    /// The files an `odoc` event names: its direct object is a list of file references (or a
230    /// single one), each coerced to a `file:` URL.
231    pub(super) fn files_of(event: &NSAppleEventDescriptor) -> Vec<Opened> {
232        let Some(direct) = event.paramDescriptorForKeyword(DIRECT_OBJECT) else { return vec![] };
233        let n = direct.numberOfItems();
234        let items: Vec<Retained<NSAppleEventDescriptor>> = if n <= 0 {
235            vec![direct]
236        } else {
237            // Apple event lists are 1-based.
238            (1..=n.min(MAX_ITEMS as isize)).filter_map(|i| direct.descriptorAtIndex(i)).collect()
239        };
240        items
241            .iter()
242            .filter_map(|d| d.coerceToDescriptorType(TYPE_FILE_URL))
243            .filter_map(|u| {
244                let data = u.data();
245                if data.len() > MAX_LEN {
246                    return None;
247                }
248                let text = String::from_utf8(data.to_vec()).ok()?;
249                file_url_to_path(text.trim_end_matches('\0')).map(Opened::File)
250            })
251            .collect()
252    }
253
254    /// The URL a `GURL` event carries.
255    pub(super) fn url_of(event: &NSAppleEventDescriptor) -> Vec<Opened> {
256        let Some(s) = event.paramDescriptorForKeyword(DIRECT_OBJECT).and_then(|d| d.stringValue()) else { return vec![] };
257        let s = s.to_string();
258        let cwd = PathBuf::from("/");
259        classify(&s, &cwd).into_iter().collect()
260    }
261
262    define_class!(
263        // SAFETY: NSObject has no subclassing requirements, and this class adds no ivars and
264        // implements no `Drop`.
265        #[unsafe(super(NSObject))]
266        #[name = "AshlarNativeOpenEventHandler"]
267        struct Handler;
268
269        impl Handler {
270            // SAFETY: the signature matches what the Apple event manager calls:
271            // `- (void)handle:(NSAppleEventDescriptor *)event withReplyEvent:(NSAppleEventDescriptor *)reply`.
272            #[unsafe(method(handleOpenDocuments:withReplyEvent:))]
273            fn handle_open(&self, event: &NSAppleEventDescriptor, _reply: &NSAppleEventDescriptor) {
274                // No panic may unwind into the Apple event manager.
275                let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| deliver(files_of(event))));
276            }
277
278            // SAFETY: as above.
279            #[unsafe(method(handleGetURL:withReplyEvent:))]
280            fn handle_url(&self, event: &NSAppleEventDescriptor, _reply: &NSAppleEventDescriptor) {
281                let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| deliver(url_of(event))));
282            }
283        }
284    );
285
286    impl Handler {
287        fn new() -> Retained<Self> {
288            let this = Self::alloc().set_ivars(());
289            // SAFETY: `init` is NSObject's designated initializer, called once on a fresh
290            // allocation.
291            unsafe { msg_send![super(this), init] }
292        }
293    }
294
295    /// The one handler object, created and installed on the main thread.
296    fn install_handlers() {
297        thread_local! {
298            static HANDLER: Retained<Handler> = Handler::new();
299        }
300        HANDLER.with(|h| {
301            let manager = NSAppleEventManager::sharedAppleEventManager();
302            let obj: &AnyObject = h.as_ref();
303            // SAFETY: `obj` is a `Handler`, which implements both selectors with the signature
304            // the Apple event manager calls. The manager does not retain the handler; it lives in
305            // a main-thread thread-local for the rest of the process, so it outlives the
306            // registration.
307            unsafe {
308                manager.setEventHandler_andSelector_forEventClass_andEventID(
309                    obj,
310                    sel!(handleOpenDocuments:withReplyEvent:),
311                    CORE_EVENT_CLASS,
312                    OPEN_DOCUMENTS,
313                );
314                manager.setEventHandler_andSelector_forEventClass_andEventID(obj, sel!(handleGetURL:withReplyEvent:), INTERNET_EVENT_CLASS, GET_URL);
315            }
316        });
317    }
318
319    pub fn listen(sender: OpenSender) -> Result<()> {
320        if MainThreadMarker::new().is_none() {
321            return Err(NativeError::Os("open-file events must be set up on the main thread".into()));
322        }
323        *SINK.lock().unwrap_or_else(PoisonError::into_inner) = Some(sender);
324        static ONCE: Once = Once::new();
325        ONCE.call_once(|| {
326            install_handlers();
327            // NSApplication installs its own handlers while it finishes launching; put ours back
328            // right after (the launch documents are sent after this notification).
329            let block = RcBlock::new(|_: NonNull<NSNotification>| install_handlers());
330            let name = NSString::from_str("NSApplicationWillFinishLaunchingNotification");
331            // SAFETY: no object filter and no queue (the block runs on the posting thread, the
332            // main thread); the block captures nothing, so it is sendable.
333            let token = unsafe { NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock(Some(&name), None, None, &block) };
334            // The observation lasts for the life of the process.
335            std::mem::forget(token);
336        });
337        Ok(())
338    }
339
340    #[cfg(test)]
341    pub(super) fn odoc_event(urls: &[&str]) -> Retained<NSAppleEventDescriptor> {
342        use objc2_foundation::NSData;
343        let list = NSAppleEventDescriptor::listDescriptor();
344        for (i, u) in urls.iter().enumerate() {
345            if let Some(d) = NSAppleEventDescriptor::descriptorWithDescriptorType_data(TYPE_FILE_URL, Some(&NSData::with_bytes(u.as_bytes()))) {
346                list.insertDescriptor_atIndex(&d, i as isize + 1);
347            }
348        }
349        let event = NSAppleEventDescriptor::appleEventWithEventClass_eventID_targetDescriptor_returnID_transactionID(
350            CORE_EVENT_CLASS,
351            OPEN_DOCUMENTS,
352            None,
353            -1,
354            0,
355        );
356        event.setParamDescriptor_forKeyword(&list, DIRECT_OBJECT);
357        event
358    }
359
360    #[cfg(test)]
361    pub(super) fn gurl_event(url: &str) -> Retained<NSAppleEventDescriptor> {
362        let event = NSAppleEventDescriptor::appleEventWithEventClass_eventID_targetDescriptor_returnID_transactionID(
363            INTERNET_EVENT_CLASS,
364            GET_URL,
365            None,
366            -1,
367            0,
368        );
369        event.setParamDescriptor_forKeyword(&NSAppleEventDescriptor::descriptorWithString(&NSString::from_str(url)), DIRECT_OBJECT);
370        event
371    }
372}
373
374#[cfg(not(target_os = "macos"))]
375mod imp {
376    use super::*;
377
378    pub fn listen(_sender: OpenSender) -> Result<()> {
379        Err(NativeError::Unsupported)
380    }
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    #[test]
388    fn arguments_become_files_and_urls() {
389        let cwd = Path::new("/work");
390        let got =
391            from_args(["-psn_0_1234", "--verbose", "a.txt", "/abs/b.txt", "myapp://open?x=1", "file:///tmp/My%20Doc.txt", "--", "-dash.txt"], cwd);
392        assert_eq!(
393            got,
394            vec![
395                Opened::File("/work/a.txt".into()),
396                Opened::File("/abs/b.txt".into()),
397                Opened::Url("myapp://open?x=1".into()),
398                Opened::File("/tmp/My Doc.txt".into()),
399                Opened::File("/work/-dash.txt".into()),
400            ]
401        );
402    }
403
404    #[test]
405    fn hostile_input_is_bounded() {
406        let cwd = Path::new("/w");
407        assert_eq!(from_args(std::iter::repeat_n("x", 100_000), cwd).len(), MAX_ITEMS);
408        assert!(classify(&"y".repeat(MAX_LEN + 1), cwd).is_none());
409        assert!(classify("", cwd).is_none());
410        assert!(classify("a\0b", cwd).is_none());
411        assert_eq!(file_url_to_path("file:///a%2"), None);
412        assert_eq!(file_url_to_path("file:///a%zz"), None);
413        assert_eq!(file_url_to_path("file:///a%00b"), None);
414        assert_eq!(file_url_to_path("file://otherhost/x"), None);
415        assert_eq!(file_url_to_path("file://localhost/x%C3%A9"), Some(PathBuf::from("/xé")));
416        assert_eq!(file_url_to_path("file:///%FF"), None, "not UTF-8");
417        // A drive letter is not a URL scheme.
418        assert!(matches!(classify("C:\\x.txt", cwd), Some(Opened::File(_))));
419    }
420
421    #[test]
422    fn the_queue_wakes_the_app_once_per_batch() {
423        let wakes = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
424        let w = wakes.clone();
425        let q = OpenFiles::new(std::sync::Arc::new(move || {
426            w.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
427        }));
428        q.push_args(["a", "b"], Path::new("/"));
429        q.push_args(Vec::<String>::new(), Path::new("/"));
430        let s = q.sender();
431        std::thread::spawn(move || s.send([Opened::Url("x://y".into())])).join().unwrap_or(false);
432        assert_eq!(q.drain().len(), 3);
433        assert!(q.drain().is_empty());
434        assert_eq!(wakes.load(std::sync::atomic::Ordering::SeqCst), 2);
435    }
436
437    #[cfg(target_os = "macos")]
438    #[test]
439    fn apple_events_are_read() {
440        let ev = imp::odoc_event(&["file:///tmp/One.txt", "file:///tmp/Two%20Words.txt", "notaurl"]);
441        assert_eq!(imp::files_of(&ev), vec![Opened::File("/tmp/One.txt".into()), Opened::File("/tmp/Two Words.txt".into())]);
442        assert_eq!(imp::url_of(&imp::gurl_event("myapp://doc/1")), vec![Opened::Url("myapp://doc/1".into())]);
443        // Off the main thread (test threads), listening is refused instead of crashing.
444        let q = OpenFiles::new(crate::no_waker());
445        assert!(q.listen_os().is_err());
446    }
447}