lingxia_webview/
url_callback.rs1use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
27use std::sync::{Mutex, OnceLock};
28
29use tokio::sync::mpsc;
30
31#[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#[derive(Debug)]
41pub struct UrlCallbackChannel {
42 id: u64,
43 callback_url: String,
44 receiver: mpsc::UnboundedReceiver<String>,
45}
46
47impl UrlCallbackChannel {
48 pub fn callback_url(&self) -> &str {
50 &self.callback_url
51 }
52
53 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 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
77pub 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
110pub 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
153fn 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 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}