Skip to main content

euv_ui/component/camera/hook/
impl.rs

1use super::*;
2
3thread_local! {
4    /// Cache of the `BarcodeDetector#detect` `Function`. There is at
5    /// most one active `BarcodeDetector` per scan session (created in
6    /// `start_qr_scan`), so a single slot is enough. The `Function` is
7    /// fetched once via `Reflect::get(detector, "detect")` and reused
8    /// across every scan tick — the per-tick `Reflect::get` +
9    /// `JsValue::from_str("detect")` allocation is eliminated.
10    static DETECT_FN_CACHE: RefCell<Option<Function>> = const { RefCell::new(None) };
11}
12
13const DETECT_FN_KEY: &str = "detect";
14
15/// Implementation of camera functionality.
16impl UseEuvCamera {
17    /// Creates camera state for controlling camera stream and QR scanning.
18    ///
19    /// # Returns
20    ///
21    /// - `UseEuvCamera` - The camera state.
22    pub fn use_camera_state() -> UseEuvCamera {
23        UseEuvCamera::new(
24            App::use_signal(|| false),
25            App::use_signal(|| false),
26            App::use_signal(String::new),
27            App::use_signal(EuvCameraFacing::default),
28            App::use_signal(String::new),
29            App::use_signal(|| None),
30        )
31    }
32
33    /// Requests camera access from the browser and binds the resulting
34    /// media stream to the `<video>` element identified by the given CSS selector.
35    ///
36    /// Uses `navigator.mediaDevices.getUserMedia` with a video-only
37    /// constraint. On success the stream is assigned as `srcObject` on
38    /// the target video element and `play()` is called. Errors are
39    /// returned as human-readable strings.
40    ///
41    /// # Arguments
42    ///
43    /// - `&str` - The CSS selector of the `<video>` element to bind the stream to.
44    /// - `EuvCameraFacing` - The desired camera facing direction.
45    ///
46    /// # Returns
47    ///
48    /// - `Result<(), String>` - `Ok(())` on success, or an error message on failure.
49    pub(crate) fn open(video_selector: &str, facing: EuvCameraFacing) -> Result<(), String> {
50        let Some(window_value) = window() else {
51            return Err("no global window exists".to_string());
52        };
53        let navigator: Navigator = window_value.navigator();
54        let media_devices: MediaDevices = navigator
55            .media_devices()
56            .map_err(|error: JsValue| format!("{error:?}"))?;
57        let constraints: MediaStreamConstraints = MediaStreamConstraints::new();
58        let facing_mode: &str = match facing {
59            EuvCameraFacing::User => CAMERA_FACING_MODE_USER,
60            EuvCameraFacing::Environment => CAMERA_FACING_MODE_ENVIRONMENT,
61        };
62        let video_constraint: Object = Object::new();
63        let _: Result<bool, JsValue> = Reflect::set(
64            &video_constraint,
65            &JsValue::from_str("facingMode"),
66            &JsValue::from_str(facing_mode),
67        );
68        constraints.set_video(&video_constraint);
69        constraints.set_audio(&JsValue::from_bool(false));
70        let promise: Promise = media_devices
71            .get_user_media_with_constraints(&constraints)
72            .map_err(|error: JsValue| format!("{error:?}"))?;
73        let selector: String = video_selector.to_string();
74        let on_fulfilled: Closure<dyn FnMut(JsValue)> =
75            Closure::wrap(Box::new(move |stream_value: JsValue| {
76                let stream: MediaStream = stream_value.unchecked_into();
77                let Some(window_value) = window() else {
78                    return;
79                };
80                let Some(document) = window_value.document() else {
81                    return;
82                };
83                if let Some(element) = document.query_selector(&selector).ok().flatten() {
84                    let video_element: HtmlVideoElement = element.unchecked_into();
85                    video_element.set_src_object(Some(&stream));
86                    let _: Result<Promise, JsValue> = video_element.play();
87                }
88            }));
89        let on_rejected: Closure<dyn FnMut(JsValue)> =
90            Closure::wrap(Box::new(move |error: JsValue| {
91                web_sys::console::log_2(&wasm_bindgen::JsValue::from_str("[euv-camera]"), &error);
92            }));
93        let _: Promise = promise.then(&on_fulfilled).catch(&on_rejected);
94        on_fulfilled.forget();
95        on_rejected.forget();
96        Ok(())
97    }
98
99    /// Stops all tracks on the media stream currently attached to the
100    /// `<video>` element identified by the given CSS selector.
101    ///
102    /// Iterates over `videoElement.srcObject.getTracks()` and calls
103    /// `stop()` on each one, then clears `srcObject`.
104    ///
105    /// # Arguments
106    ///
107    /// - `&str` - The CSS selector of the `<video>` element whose stream should be stopped.
108    pub(crate) fn close(video_selector: &str) {
109        let Some(window_value) = window() else {
110            return;
111        };
112        let Some(document) = window_value.document() else {
113            return;
114        };
115        if let Some(element) = document.query_selector(video_selector).ok().flatten() {
116            let video_element: HtmlVideoElement = element.unchecked_into();
117            if let Some(stream) = video_element.src_object() {
118                let stream: MediaStream = stream.unchecked_into();
119                let tracks: Array = stream.get_tracks();
120                for track_value in tracks.iter() {
121                    let track: MediaStreamTrack = track_value.unchecked_into();
122                    track.stop();
123                }
124            }
125            video_element.set_src_object(None);
126        }
127    }
128
129    /// Opens the camera, starts QR code scanning immediately, and updates
130    /// the state signals accordingly.
131    ///
132    /// If the camera fails to open, the error message signal is set.
133    ///
134    /// # Arguments
135    ///
136    /// - `Option<&EuvCameraConfig>` - Optional camera configuration.
137    pub(crate) fn open_and_scan(self, config: Option<&EuvCameraConfig>) {
138        let cfg: EuvCameraConfig =
139            config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
140        self.get_camera_loading().set(true);
141        self.get_error_message().set(String::new());
142        self.get_scan_result().set(String::new());
143        let facing: EuvCameraFacing = self.get_facing().get();
144        let result: Result<(), String> = Self::open(cfg.video_selector, facing);
145        match result {
146            Ok(()) => {
147                self.get_camera_open().set(true);
148                self.get_camera_loading().set(false);
149                if cfg.auto_scan {
150                    self.start_qr_scan(config);
151                }
152            }
153            Err(error) => {
154                self.get_error_message().set(error);
155                self.get_camera_loading().set(false);
156                if let Some(ref on_error) = cfg.on_error {
157                    on_error(self.get_error_message().get());
158                }
159            }
160        }
161    }
162
163    /// Switches the camera to the opposite facing direction and restarts
164    /// QR code scanning.
165    ///
166    /// Closes the current camera stream and reopens with the new facing
167    /// mode. On success, QR code scanning is started automatically.
168    ///
169    /// # Arguments
170    ///
171    /// - `Option<&EuvCameraConfig>` - Optional camera configuration.
172    pub(crate) fn switch(self, config: Option<&EuvCameraConfig>) {
173        let cfg: EuvCameraConfig =
174            config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
175        self.stop_qr_scan();
176        Self::close(cfg.video_selector);
177        self.get_camera_open().set(false);
178        let new_facing: EuvCameraFacing = match self.get_facing().get() {
179            EuvCameraFacing::User => EuvCameraFacing::Environment,
180            EuvCameraFacing::Environment => EuvCameraFacing::User,
181        };
182        self.get_facing().set(new_facing);
183        self.get_camera_loading().set(true);
184        self.get_error_message().set(String::new());
185        let result: Result<(), String> = Self::open(cfg.video_selector, new_facing);
186        match result {
187            Ok(()) => {
188                self.get_camera_open().set(true);
189                self.get_camera_loading().set(false);
190                if cfg.auto_scan {
191                    self.start_qr_scan(config);
192                }
193            }
194            Err(error) => {
195                self.get_error_message().set(error);
196                self.get_camera_loading().set(false);
197            }
198        }
199    }
200
201    /// Returns the cached `BarcodeDetector#detect` `Function`, populating
202    /// the cache on first lookup.
203    ///
204    /// The original code called `Reflect::get(detector, "detect")` every
205    /// scan tick, allocating a fresh `JsValue::from_str("detect")` JS
206    /// string and crossing the FFI boundary. The cached `Function` is
207    /// reused for the lifetime of the scan session.
208    ///
209    /// # Arguments
210    ///
211    /// - `&JsValue` - The `BarcodeDetector` instance.
212    ///
213    /// # Returns
214    ///
215    /// - `Function` - A clone of the cached `detect` function, or a
216    ///   no-op `Promise.resolve([])` fallback when lookup fails.
217    fn cached_detect_fn(detector: &JsValue) -> Function {
218        DETECT_FN_CACHE.with(|cache: &RefCell<Option<Function>>| {
219            if let Some(function) = cache.borrow().as_ref() {
220                return function.clone();
221            }
222            let function: Function = Reflect::get(detector, &JsValue::from_str(DETECT_FN_KEY))
223                .ok()
224                .and_then(|value: JsValue| value.dyn_into::<Function>().ok())
225                .unwrap_or_else(|| Function::new_no_args("return Promise.resolve([])"));
226            *cache.borrow_mut() = Some(function.clone());
227            function
228        })
229    }
230
231    /// Starts a periodic QR code scan using the browser `BarcodeDetector` API.
232    ///
233    /// If the browser does not support `BarcodeDetector`, the scan is not
234    /// started and the error signal is set. On each interval tick, captures
235    /// the current video frame and attempts to detect a QR code. If a QR
236    /// code is found, the result is stored in `scan_result`. If the result
237    /// is an HTTP URL, the browser navigates directly to that URL.
238    ///
239    /// The on_detected / on_scan_error `Closure`s are created once per
240    /// scan session and stored in `ON_QR_DETECTED_CLOSURE` /
241    /// `ON_QR_SCAN_ERROR_CLOSURE` thread-locals; per-tick
242    /// `Closure::wrap` + `.forget()` is eliminated (memory leak fix
243    /// from audit #26). The `detect` method is cached in
244    /// `DETECT_FN_CACHE` (audit #26 per-tick `Reflect::get` cost).
245    ///
246    /// # Arguments
247    ///
248    /// - `Option<&EuvCameraConfig>` - Optional camera configuration.
249    pub(crate) fn start_qr_scan(self, config: Option<&EuvCameraConfig>) {
250        let cfg: EuvCameraConfig =
251            config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
252        let Some(window_value) = window() else {
253            return;
254        };
255        let barcode_detector_key: JsValue = JsValue::from_str("BarcodeDetector");
256        let barcode_detector_constructor: Function =
257            match Reflect::get(&window_value, &barcode_detector_key) {
258                Ok(value) if !value.is_undefined() && !value.is_null() => value.unchecked_into(),
259                _ => {
260                    self.get_error_message()
261                        .set("BarcodeDetector API is not supported in this browser".to_string());
262                    return;
263                }
264            };
265        let formats_array: Array = Array::new();
266        formats_array.push(&JsValue::from_str("qr_code"));
267        let init_object: Object = Object::new();
268        let _: Result<bool, JsValue> =
269            Reflect::set(&init_object, &JsValue::from_str("formats"), &formats_array);
270        let args_array: Array = Array::new();
271        args_array.push(&init_object.into());
272        let detector: JsValue = match Reflect::construct(&barcode_detector_constructor, &args_array)
273        {
274            Ok(value) => value,
275            Err(error) => {
276                self.get_error_message()
277                    .set(format!("Failed to create BarcodeDetector: {error:?}"));
278                return;
279            }
280        };
281        let video_selector: Rc<String> = Rc::new(cfg.video_selector.to_string());
282        let on_qr_detected: Option<QrDetectedCallback> = cfg.on_qr_detected.clone();
283        let self_for_closure: UseEuvCamera = self;
284        let video_selector_for_closure: Rc<String> = video_selector.clone();
285        let on_qr_detected_for_closure: Option<QrDetectedCallback> = on_qr_detected.clone();
286        let on_detected: Closure<dyn FnMut(JsValue)> =
287            Closure::wrap(Box::new(move |barcodes_value: JsValue| {
288                let barcodes: Array = match barcodes_value.dyn_into::<Array>() {
289                    Ok(array) => array,
290                    Err(_) => return,
291                };
292                if barcodes.length() == 0 {
293                    return;
294                }
295                let text: Option<String> = barcodes.get(0).as_string().or_else(|| {
296                    Reflect::get(&barcodes.get(0), &JsValue::from_str("rawValue"))
297                        .ok()
298                        .and_then(|v: JsValue| v.as_string())
299                });
300                if let Some(text) = text {
301                    self_for_closure.get_scan_result().set(text.clone());
302                    if let Some(ref callback) = on_qr_detected_for_closure {
303                        callback(&text);
304                    }
305                    if Self::is_valid_qr_url(&text) {
306                        self_for_closure.stop_qr_scan();
307                        Self::close(&video_selector_for_closure);
308                        self_for_closure.get_camera_open().set(false);
309                        Self::navigate_qr_url(&text);
310                    }
311                }
312            }));
313        let on_scan_error: Closure<dyn FnMut(JsValue)> =
314            Closure::wrap(Box::new(move |_error: JsValue| {}));
315        let detect_fn: Function = Self::cached_detect_fn(&detector);
316        // Cache the video element across scan ticks: `query_selector` costs
317        // one JS crossing plus a JS-side selector parse per tick; the element
318        // is stable for a scan session and is re-validated cheaply via
319        // `is_connected` (re-resolved if the DOM node was swapped).
320        let video_element_cache: Rc<RefCell<Option<HtmlVideoElement>>> =
321            Rc::new(RefCell::new(None));
322        let handle: IntervalHandle = App::use_interval(cfg.scan_interval_millis, move || {
323            let on_detected: &Closure<dyn FnMut(JsValue)> = &on_detected;
324            let on_scan_error: &Closure<dyn FnMut(JsValue)> = &on_scan_error;
325            let detect_fn: &Function = &detect_fn;
326            let Some(window_value) = window() else {
327                return;
328            };
329            let Some(document) = window_value.document() else {
330                return;
331            };
332            let video_element: HtmlVideoElement = {
333                let mut cache: std::cell::RefMut<'_, Option<HtmlVideoElement>> =
334                    video_element_cache.borrow_mut();
335                match cache.as_ref() {
336                    Some(cached) if cached.is_connected() => cached.clone(),
337                    _ => {
338                        let Some(element) = document.query_selector(&video_selector).ok().flatten()
339                        else {
340                            return;
341                        };
342                        let resolved: HtmlVideoElement = element.unchecked_into();
343                        *cache = Some(resolved.clone());
344                        resolved
345                    }
346                }
347            };
348            if video_element.ready_state() != HtmlMediaElement::HAVE_ENOUGH_DATA {
349                return;
350            }
351            let promise: Promise = match detect_fn.call1(&detector, &video_element) {
352                Ok(result) => result.into(),
353                Err(_) => return,
354            };
355            let _: Promise = promise.then(on_detected).catch(on_scan_error);
356        });
357        self_for_closure.get_scan_handle().set(Some(handle));
358    }
359
360    /// Stops the periodic QR code scan timer if it is running.
361    pub(crate) fn stop_qr_scan(self) {
362        if let Some(handle) = self.get_scan_handle().get() {
363            handle.clear();
364            self.get_scan_handle().set(None);
365        }
366        DETECT_FN_CACHE.with(|cache: &RefCell<Option<Function>>| {
367            *cache.borrow_mut() = None;
368        });
369    }
370
371    /// Checks whether the given string is a valid QR code URL that the
372    /// camera scanner should navigate to.
373    ///
374    /// A valid URL must start with `http://` or `https://`.
375    ///
376    /// # Arguments
377    ///
378    /// - `&str` - The string to check.
379    ///
380    /// # Returns
381    ///
382    /// - `bool` - `true` if the string is a valid HTTP or HTTPS URL.
383    pub(crate) fn is_valid_qr_url(text: &str) -> bool {
384        text.starts_with(CAMERA_URL_PREFIX_HTTP) || text.starts_with(CAMERA_URL_PREFIX_HTTPS)
385    }
386
387    /// Extracts the hostname from an absolute URL string using pure Rust
388    /// string parsing.
389    ///
390    /// Supports `http://` and `https://` schemes, strips IPv6 brackets,
391    /// and ignores the port portion. Returns an empty string if the URL
392    /// format is not recognised.
393    ///
394    /// # Arguments
395    ///
396    /// - `&str` - The absolute URL to parse.
397    ///
398    /// # Returns
399    ///
400    /// - `String` - The extracted hostname, or an empty string on failure.
401    pub(crate) fn extract_hostname(url: &str) -> String {
402        let rest: &str = if let Some(stripped) = url.strip_prefix(CAMERA_URL_PREFIX_HTTPS) {
403            stripped
404        } else if let Some(stripped) = url.strip_prefix(CAMERA_URL_PREFIX_HTTP) {
405            stripped
406        } else {
407            return String::new();
408        };
409        let authority: &str = rest.split('/').next().unwrap_or("");
410        let host_with_brackets: &str = authority.split(':').next().unwrap_or("");
411        if let Some(stripped) = host_with_brackets.strip_prefix('[')
412            && let Some(inner) = stripped.strip_suffix(']')
413        {
414            return inner.to_string();
415        }
416        host_with_brackets.to_string()
417    }
418
419    /// Checks whether the given hostname is a private or loopback IP
420    /// address.
421    ///
422    /// Recognises loopback (`127.0.0.0/8`), link-local (`169.254.0.0/16`),
423    /// RFC 1918 private ranges (`10.0.0.0/8`, `172.16.0.0/12`,
424    /// `192.168.0.0/16`), and the `localhost` hostname.
425    ///
426    /// # Arguments
427    ///
428    /// - `&str` - The hostname to inspect.
429    ///
430    /// # Returns
431    ///
432    /// - `bool` - `true` if the hostname is a private/internal address.
433    pub(crate) fn is_private_host(hostname: &str) -> bool {
434        if hostname.is_empty() {
435            return false;
436        }
437        if hostname.eq_ignore_ascii_case(CAMERA_LOCALHOST_HOSTNAME) {
438            return true;
439        }
440        let octets: Vec<&str> = hostname.split('.').collect();
441        if octets.len() != 4 {
442            return false;
443        }
444        let Ok(first) = octets[0].parse::<u8>() else {
445            return false;
446        };
447        let Ok(second) = octets[1].parse::<u8>() else {
448            return false;
449        };
450        if first == 127 {
451            return true;
452        }
453        if first == 10 {
454            return true;
455        }
456        if first == 172 && (16..=31).contains(&second) {
457            return true;
458        }
459        if first == 192 && second == 168 {
460            return true;
461        }
462        if first == 169 && second == 254 {
463            return true;
464        }
465        false
466    }
467
468    /// Navigates to the URL detected from a QR code.
469    ///
470    /// If the URL points to the same origin (current host), extracts the
471    /// hash fragment route and navigates internally using `navigate`.
472    /// If the URL host is a private/internal IP address, performs a full
473    /// page navigation via `location.href` within the current browser.
474    /// Otherwise (external public URL), opens the link in the system
475    /// browser via `window.open` so the user stays in the app.
476    ///
477    /// # Arguments
478    ///
479    /// - `&str` - The URL to navigate to.
480    pub(crate) fn navigate_qr_url(url: &str) {
481        let Some(window_value) = window() else {
482            return;
483        };
484        let location: Location = window_value.location();
485        let current_hostname: String = location.hostname().unwrap_or_default();
486        let url_hostname: String = Self::extract_hostname(url);
487        if url_hostname == current_hostname
488            && let Some(fragment) = url.split('#').nth(1)
489        {
490            let route: &str = if fragment.is_empty() { "/" } else { fragment };
491            Router::navigate(route);
492            return;
493        }
494        if Self::is_private_host(&url_hostname) {
495            let _: Result<(), JsValue> = window_value.location().set_href(url);
496            return;
497        }
498        if let Ok(open_fn) = Reflect::get(&window_value, &JsValue::from_str("open"))
499            .and_then(|value: JsValue| value.dyn_into::<Function>())
500        {
501            let _: Result<JsValue, JsValue> = open_fn.call2(
502                &window_value,
503                &JsValue::from_str(url),
504                &JsValue::from_str(SYSTEM_BROWSER_TARGET),
505            );
506        }
507    }
508
509    /// Creates a click event handler that closes the camera stream and
510    /// stops the QR code scan.
511    ///
512    /// # Arguments
513    ///
514    /// - `Option<&EuvCameraConfig>` - Optional camera configuration.
515    ///
516    /// # Returns
517    ///
518    /// - `Option<Rc<dyn Fn(Event)>>` - A click event handler.
519    pub fn on_close(self, config: Option<&EuvCameraConfig>) -> Option<Rc<dyn Fn(Event)>> {
520        let cfg: EuvCameraConfig =
521            config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
522        Some(Rc::new(move |_: Event| {
523            self.stop_qr_scan();
524            Self::close(cfg.video_selector);
525            self.get_camera_open().set(false);
526            self.get_scan_result().set(String::new());
527        }))
528    }
529
530    /// Creates a click event handler that switches the camera facing direction.
531    ///
532    /// # Arguments
533    ///
534    /// - `Option<&EuvCameraConfig>` - Optional camera configuration.
535    ///
536    /// # Returns
537    ///
538    /// - `Option<Rc<dyn Fn(Event)>>` - A click event handler.
539    pub fn on_switch(self, config: Option<&EuvCameraConfig>) -> Option<Rc<dyn Fn(Event)>> {
540        let cfg: EuvCameraConfig =
541            config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
542        Some(Rc::new(move |_: Event| {
543            self.switch(Some(&cfg));
544        }))
545    }
546
547    /// Creates a click event handler that opens the camera and starts QR scanning.
548    ///
549    /// # Arguments
550    ///
551    /// - `Option<&EuvCameraConfig>` - Optional camera configuration.
552    ///
553    /// # Returns
554    ///
555    /// - `Option<Rc<dyn Fn(Event)>>` - A click event handler.
556    pub fn on_open(self, config: Option<&EuvCameraConfig>) -> Option<Rc<dyn Fn(Event)>> {
557        let cfg: EuvCameraConfig =
558            config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
559        Some(Rc::new(move |_: Event| {
560            self.open_and_scan(Some(&cfg));
561        }))
562    }
563
564    /// Registers a cleanup callback that closes the camera stream and
565    /// stops the QR code scan timer when the component unmounts or
566    /// the page route switches away.
567    ///
568    /// # Arguments
569    ///
570    /// - `Option<&EuvCameraConfig>` - Optional camera configuration.
571    pub fn cleanup(self, config: Option<&EuvCameraConfig>) {
572        let cfg: EuvCameraConfig =
573            config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
574        App::use_cleanup(move || {
575            self.stop_qr_scan();
576            Self::close(cfg.video_selector);
577            self.get_camera_open().set(false);
578            self.get_camera_loading().set(false);
579            self.get_error_message().set(String::new());
580            self.get_scan_result().set(String::new());
581        });
582    }
583}
584
585/// Default implementation for `EuvCameraConfig`.
586impl Default for EuvCameraConfig {
587    /// Constructs a default [`EuvCameraConfig`] value.
588    fn default() -> Self {
589        EuvCameraConfig {
590            video_selector: CAMERA_VIDEO_SELECTOR,
591            scan_interval_millis: CAMERA_SCAN_INTERVAL_MILLIS,
592            auto_scan: true,
593            on_qr_detected: None,
594            on_error: None,
595        }
596    }
597}