1use std::ffi::OsStr;
18use std::path::{Path, PathBuf};
19use std::sync::mpsc::{Receiver, Sender, channel};
20
21use crate::{NativeError, Result, Waker};
22
23pub const MAX_ITEMS: usize = 1024;
25pub const MAX_LEN: usize = 32 * 1024;
27
28#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
30#[serde(rename_all = "camelCase")]
31pub enum Opened {
32 File(PathBuf),
34 Url(String),
36}
37
38fn 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
48pub 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 #[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
79pub 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 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
96pub 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#[derive(Clone)]
126pub struct OpenSender {
127 tx: Sender<Opened>,
128 waker: Waker,
129}
130
131impl OpenSender {
132 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
148pub struct OpenFiles {
150 sender: OpenSender,
151 rx: Receiver<Opened>,
152}
153
154impl OpenFiles {
155 pub fn new(waker: Waker) -> Self {
157 let (tx, rx) = channel();
158 Self { sender: OpenSender { tx, waker }, rx }
159 }
160
161 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 pub fn sender(&self) -> OpenSender {
176 self.sender.clone()
177 }
178
179 pub fn drain(&self) -> Vec<Opened> {
181 self.rx.try_iter().take(MAX_ITEMS).collect()
182 }
183
184 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 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 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 (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 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 #[unsafe(super(NSObject))]
266 #[name = "AshlarNativeOpenEventHandler"]
267 struct Handler;
268
269 impl Handler {
270 #[unsafe(method(handleOpenDocuments:withReplyEvent:))]
273 fn handle_open(&self, event: &NSAppleEventDescriptor, _reply: &NSAppleEventDescriptor) {
274 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| deliver(files_of(event))));
276 }
277
278 #[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 unsafe { msg_send![super(this), init] }
292 }
293 }
294
295 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 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 let block = RcBlock::new(|_: NonNull<NSNotification>| install_handlers());
330 let name = NSString::from_str("NSApplicationWillFinishLaunchingNotification");
331 let token = unsafe { NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock(Some(&name), None, None, &block) };
334 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 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 let q = OpenFiles::new(crate::no_waker());
445 assert!(q.listen_os().is_err());
446 }
447}