Skip to main content

lingxia_webview/
url_callback.rs

1//! Process-local URL callback channels for WebView navigation.
2//!
3//! A caller that needs to observe a specific navigation — and receive its URL
4//! without loading it — opens a channel for that URL, then awaits it:
5//!
6//! ```no_run
7//! # async fn wait_for() -> Result<(), lingxia_webview::url_callback::InvalidCallbackUrl> {
8//! let mut callback = lingxia_webview::url_callback::open_channel("myapp://callback")?;
9//! // ... drive the WebView, then:
10//! let url = callback.recv().await; // "myapp://callback?..."
11//! # Ok(())
12//! # }
13//! ```
14//!
15//! While a channel is open, any managed WebView navigation (or new-window
16//! request) whose URL matches the channel's callback URL is cancelled instead
17//! of loading, and the full URL is delivered to the channel. The callback URL
18//! is a plain navigation-target string: no AppLink, OS custom-scheme
19//! registration, or scheme handler is involved.
20//!
21//! Interception is process-wide — any web content able to navigate can hit the
22//! callback URL — so the caller should tie the delivered URL back to the flow
23//! that opened the channel (e.g. via a query parameter it planted) before
24//! trusting it.
25
26use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
27use std::sync::{Mutex, OnceLock};
28
29use tokio::sync::mpsc;
30
31/// Error returned by [`open_channel`] for an unusable callback URL.
32#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
33#[error("invalid callback URL {url:?}: must be an absolute URL with a scheme")]
34pub struct InvalidCallbackUrl {
35    pub url: String,
36}
37
38/// Receives WebView navigations intercepted for its callback URL.
39/// Dropping the channel stops the interception.
40#[derive(Debug)]
41pub struct UrlCallbackChannel {
42    id: u64,
43    callback_url: String,
44    receiver: mpsc::UnboundedReceiver<String>,
45}
46
47impl UrlCallbackChannel {
48    /// The callback URL this channel matches, in normalized (match-key) form.
49    pub fn callback_url(&self) -> &str {
50        &self.callback_url
51    }
52
53    /// Waits for the next navigation to the callback URL and returns the full
54    /// navigated URL, query and fragment included.
55    ///
56    /// Pends indefinitely until a matching navigation happens — bound the wait
57    /// externally (a timeout, or racing dismissal of the presenting surface).
58    pub async fn recv(&mut self) -> String {
59        self.receiver
60            .recv()
61            .await
62            .expect("registry holds the sender for the channel's lifetime")
63    }
64
65    /// Returns an already-intercepted URL without waiting.
66    pub fn try_recv(&mut self) -> Option<String> {
67        self.receiver.try_recv().ok()
68    }
69}
70
71impl Drop for UrlCallbackChannel {
72    fn drop(&mut self) {
73        unregister(self.id);
74    }
75}
76
77/// Open a callback channel for `callback_url`, e.g. `"myapp://callback"`.
78///
79/// Matching compares whole URLs with the query, fragment, and any trailing `/`
80/// ignored; the scheme and authority compare case-insensitively, the path
81/// exactly. `myapp://callback?k=v` matches a channel opened for
82/// `myapp://callback`; `myapp://callback/extra` does not.
83///
84/// Channels may overlap: the most recently opened channel matching a URL
85/// receives it. Errs when `callback_url` is empty or has no scheme.
86pub fn open_channel(
87    callback_url: impl Into<String>,
88) -> Result<UrlCallbackChannel, InvalidCallbackUrl> {
89    let raw = callback_url.into();
90    let Some(key) = match_key(&raw) else {
91        return Err(InvalidCallbackUrl { url: raw });
92    };
93    let (sender, receiver) = mpsc::unbounded_channel();
94    let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
95    let mut entries = registry().lock().unwrap();
96    entries.push(Entry {
97        id,
98        callback_url: key.clone(),
99        sender,
100    });
101    CHANNEL_COUNT.store(entries.len(), Ordering::Release);
102    drop(entries);
103    Ok(UrlCallbackChannel {
104        id,
105        callback_url: key,
106        receiver,
107    })
108}
109
110/// Route a navigation URL to the most recently opened matching channel.
111/// Returns `true` when a channel took it, in which case the navigation must be
112/// cancelled. Managed WebViews dispatch automatically in `handle_navigation`;
113/// this is public for platform surfaces backed by a bare native WebView, whose
114/// navigation delegates must offer the URL themselves. Called for every
115/// navigation, so the no-channel path is one atomic load.
116pub fn dispatch(url: &str) -> bool {
117    if CHANNEL_COUNT.load(Ordering::Acquire) == 0 {
118        return false;
119    }
120    let Some(key) = match_key(url) else {
121        return false;
122    };
123    let entries = registry().lock().unwrap();
124    entries
125        .iter()
126        .rev()
127        .any(|entry| entry.callback_url == key && entry.sender.send(url.trim().to_string()).is_ok())
128}
129
130#[derive(Debug)]
131struct Entry {
132    id: u64,
133    callback_url: String,
134    sender: mpsc::UnboundedSender<String>,
135}
136
137static NEXT_ID: AtomicU64 = AtomicU64::new(1);
138static CHANNEL_COUNT: AtomicUsize = AtomicUsize::new(0);
139static REGISTRY: OnceLock<Mutex<Vec<Entry>>> = OnceLock::new();
140
141fn registry() -> &'static Mutex<Vec<Entry>> {
142    REGISTRY.get_or_init(|| Mutex::new(Vec::new()))
143}
144
145fn unregister(id: u64) {
146    if let Some(registry) = REGISTRY.get() {
147        let mut entries = registry.lock().unwrap();
148        entries.retain(|entry| entry.id != id);
149        CHANNEL_COUNT.store(entries.len(), Ordering::Release);
150    }
151}
152
153/// Normalize a URL to its match key: query/fragment stripped, trailing `/`
154/// trimmed, scheme and authority lowercased (both are case-insensitive per
155/// RFC 3986), path left exact. `None` when the URL has no usable scheme.
156fn match_key(url: &str) -> Option<String> {
157    let base = strip_query_fragment(url.trim()).trim_end_matches('/');
158    let scheme_end = base.find(':')?;
159    let (scheme, rest) = base.split_at(scheme_end);
160    if scheme.is_empty()
161        || !scheme
162            .chars()
163            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
164    {
165        return None;
166    }
167    let mut key = scheme.to_ascii_lowercase();
168    if let Some(authority_and_path) = rest.strip_prefix("://") {
169        let (authority, path) = match authority_and_path.find('/') {
170            Some(idx) => authority_and_path.split_at(idx),
171            None => (authority_and_path, ""),
172        };
173        key.push_str("://");
174        key.push_str(&authority.to_ascii_lowercase());
175        key.push_str(path);
176    } else {
177        key.push_str(rest);
178    }
179    Some(key)
180}
181
182fn strip_query_fragment(value: &str) -> &str {
183    let end = value.find(['?', '#']).unwrap_or(value.len());
184    &value[..end]
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn channel_receives_matching_custom_scheme_url() {
193        let mut channel = open_channel("lingxia-test-basic://callback").expect("open channel");
194
195        assert!(dispatch("lingxia-test-basic://callback?code=abc&next=%2F"));
196        assert_eq!(
197            channel.try_recv().as_deref(),
198            Some("lingxia-test-basic://callback?code=abc&next=%2F")
199        );
200    }
201
202    #[test]
203    fn https_callback_matches_exact_path_only() {
204        let mut channel =
205            open_channel("https://auth.test-exact.example/callback").expect("open channel");
206
207        assert!(!dispatch("https://auth.test-exact.example/other?code=abc"));
208        assert!(!dispatch("https://auth.test-exact.example/callback/extra"));
209        assert!(channel.try_recv().is_none());
210
211        assert!(dispatch(
212            "https://auth.test-exact.example/callback/?code=abc#frag"
213        ));
214        assert_eq!(
215            channel.try_recv().as_deref(),
216            Some("https://auth.test-exact.example/callback/?code=abc#frag")
217        );
218    }
219
220    #[test]
221    fn scheme_and_authority_match_case_insensitively() {
222        let mut channel = open_channel("Lingxia-Test-Case://CallBack/Path").expect("open channel");
223
224        assert!(dispatch("lingxia-test-case://callback/Path?x=1"));
225        assert_eq!(
226            channel.try_recv().as_deref(),
227            Some("lingxia-test-case://callback/Path?x=1")
228        );
229        // The path is case-sensitive.
230        assert!(!dispatch("lingxia-test-case://callback/path"));
231    }
232
233    #[test]
234    fn newest_matching_channel_wins_until_dropped() {
235        let mut first = open_channel("lingxia-test-lifo://callback").expect("open first");
236        let mut second = open_channel("lingxia-test-lifo://callback").expect("open second");
237
238        assert!(dispatch("lingxia-test-lifo://callback?to=second"));
239        assert!(first.try_recv().is_none());
240        assert_eq!(
241            second.try_recv().as_deref(),
242            Some("lingxia-test-lifo://callback?to=second")
243        );
244
245        drop(second);
246        assert!(dispatch("lingxia-test-lifo://callback?to=first"));
247        assert_eq!(
248            first.try_recv().as_deref(),
249            Some("lingxia-test-lifo://callback?to=first")
250        );
251    }
252
253    #[test]
254    fn dropped_channel_stops_interception() {
255        let channel = open_channel("lingxia-test-drop://callback").expect("open channel");
256        drop(channel);
257
258        assert!(!dispatch("lingxia-test-drop://callback?code=abc"));
259    }
260
261    #[test]
262    fn rejects_callback_url_without_scheme() {
263        assert!(open_channel("").is_err());
264        assert!(open_channel("   ").is_err());
265        assert!(open_channel("callback").is_err());
266        assert!(open_channel("/path/only").is_err());
267        assert!(open_channel(":no-scheme").is_err());
268    }
269
270    #[tokio::test]
271    async fn recv_returns_dispatched_url() {
272        let mut channel = open_channel("lingxia-test-recv://callback").expect("open channel");
273        assert!(dispatch("lingxia-test-recv://callback?ok=1"));
274        assert_eq!(channel.recv().await, "lingxia-test-recv://callback?ok=1");
275    }
276}