Skip to main content

day_core/
urls.rs

1// Copyright © The Daybrite Project
2// SPDX-License-Identifier: MPL-2.0
3//! Raw external URL delivery; application handlers may consume data links before routing.
4use std::{cell::RefCell, collections::VecDeque, rc::Rc, sync::Mutex};
5static PENDING: Mutex<VecDeque<String>> = Mutex::new(VecDeque::new());
6day_reactive::tls_slots! {
7    urls;
8    static HANDLERS: RefCell<Vec<Rc<dyn Fn(&str) -> bool>>> = const { RefCell::new(Vec::new()) };
9}
10/// Register on the UI thread. Return true to consume a URL; false retains normal deep-link
11/// routing. Delivery never performs a privileged action.
12///
13/// Handlers stack: the newest registration is asked first, and a URL it declines goes to the
14/// ones before it, so a part that awaits a callback URL (a sign-in's redirect) can register
15/// for the duration of its scope without displacing the application's own handler. Each
16/// registration leaves the stack when the scope it was made in is disposed.
17pub fn on_open_url(handler: impl Fn(&str) -> bool + 'static) {
18    let handler: Rc<dyn Fn(&str) -> bool> = Rc::new(handler);
19    HANDLERS.with(|h| h.borrow_mut().push(handler.clone()));
20    day_reactive::Scope::current().on_cleanup(move || {
21        let _ = HANDLERS.try_with(|h| {
22            h.borrow_mut()
23                .retain(|current| !Rc::ptr_eq(current, &handler));
24        });
25    });
26    if day_reactive::has_main_poster() {
27        day_reactive::on_main(drain);
28    }
29}
30/// Platform intake. Keeps the original URL, including its scheme, query and escaping.
31pub fn request_open_url(url: &str) {
32    if url.is_empty() {
33        return;
34    }
35    PENDING
36        .lock()
37        .unwrap_or_else(|e| e.into_inner())
38        .push_back(url.into());
39    if day_reactive::has_main_poster() {
40        day_reactive::on_main(drain);
41    }
42}
43pub(crate) fn drain() {
44    drain_queue(&PENDING);
45}
46fn drain_queue(queue: &Mutex<VecDeque<String>>) {
47    loop {
48        let next = queue.lock().unwrap_or_else(|e| e.into_inner()).pop_front();
49        let Some(url) = next else { break };
50        // Newest first; a snapshot, so a handler may register or unregister another.
51        let handlers: Vec<_> = HANDLERS.with(|h| h.borrow().iter().rev().cloned().collect());
52        let consumed = handlers.iter().any(|h| h(&url));
53        if !consumed {
54            crate::request_route(&day_spec::route_of_url(&url));
55        }
56    }
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62    #[test]
63    fn original_urls_are_queued_reentrant_and_scoped() {
64        let seen = Rc::new(RefCell::new(Vec::new()));
65        // No main poster exists in this unit test: cold data survives until the root installs.
66        let queue = std::sync::Arc::new(Mutex::new(VecDeque::from([
67            "feed:https://fixture.example/rss?q=a%26b".into(),
68        ])));
69        let again = queue.clone();
70        let scope = day_reactive::Scope::child();
71        let received = seen.clone();
72        scope.enter(|| {
73            on_open_url(move |url| {
74                received.borrow_mut().push(url.to_owned());
75                if url.starts_with("feed:") {
76                    again
77                        .lock()
78                        .unwrap()
79                        .push_back("atom:https://fixture.example/atom".into());
80                }
81                true
82            })
83        });
84        drain_queue(&queue);
85        queue
86            .lock()
87            .unwrap()
88            .push_back("rss://fixture.example/rss".into());
89        drain_queue(&queue);
90        assert_eq!(
91            *seen.borrow(),
92            [
93                "feed:https://fixture.example/rss?q=a%26b",
94                "atom:https://fixture.example/atom",
95                "rss://fixture.example/rss"
96            ]
97        );
98        scope.dispose();
99        assert!(HANDLERS.with(|h| h.borrow().is_empty()));
100    }
101
102    /// A later registration is asked first and may decline, which hands the URL on to the
103    /// earlier one; disposing its scope removes only its own entry.
104    #[test]
105    fn handlers_stack_newest_first_and_leave_with_their_scope() {
106        let app_seen = Rc::new(RefCell::new(Vec::new()));
107        let part_seen = Rc::new(RefCell::new(Vec::new()));
108        let queue = std::sync::Arc::new(Mutex::new(VecDeque::new()));
109        let app_scope = day_reactive::Scope::child();
110        let received = app_seen.clone();
111        app_scope.enter(|| {
112            on_open_url(move |url| {
113                received.borrow_mut().push(url.to_owned());
114                true
115            })
116        });
117        let part_scope = day_reactive::Scope::child();
118        let received = part_seen.clone();
119        part_scope.enter(|| {
120            on_open_url(move |url| {
121                let mine = url.starts_with("myapp://auth");
122                if mine {
123                    received.borrow_mut().push(url.to_owned());
124                }
125                mine
126            })
127        });
128        queue.lock().unwrap().extend([
129            "myapp://auth?code=1".to_string(),
130            "myapp://inbox".to_string(),
131        ]);
132        drain_queue(&queue);
133        assert_eq!(*part_seen.borrow(), ["myapp://auth?code=1"]);
134        assert_eq!(*app_seen.borrow(), ["myapp://inbox"]);
135        part_scope.dispose();
136        assert_eq!(HANDLERS.with(|h| h.borrow().len()), 1);
137        queue
138            .lock()
139            .unwrap()
140            .push_back("myapp://auth?code=2".into());
141        drain_queue(&queue);
142        assert_eq!(*app_seen.borrow(), ["myapp://inbox", "myapp://auth?code=2"]);
143        app_scope.dispose();
144        assert!(HANDLERS.with(|h| h.borrow().is_empty()));
145    }
146}