Skip to main content

euv_ui/component/camera/hook/
impl.rs

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