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        // Cache the video element across scan ticks: `query_selector` costs
318        // one JS crossing plus a JS-side selector parse per tick; the element
319        // is stable for a scan session and is re-validated cheaply via
320        // `is_connected` (re-resolved if the DOM node was swapped).
321        let video_element_cache: Rc<RefCell<Option<HtmlVideoElement>>> =
322            Rc::new(RefCell::new(None));
323        let handle: IntervalHandle = App::use_interval(cfg.scan_interval_millis, move || {
324            let on_detected: &Closure<dyn FnMut(JsValue)> = &on_detected;
325            let on_scan_error: &Closure<dyn FnMut(JsValue)> = &on_scan_error;
326            let detect_fn: &Function = &detect_fn;
327            let Some(window_value) = window() else {
328                return;
329            };
330            let Some(document) = window_value.document() else {
331                return;
332            };
333            let video_element: HtmlVideoElement = {
334                let mut cache: std::cell::RefMut<'_, Option<HtmlVideoElement>> =
335                    video_element_cache.borrow_mut();
336                match cache.as_ref() {
337                    Some(cached) if cached.is_connected() => cached.clone(),
338                    _ => {
339                        let Some(element) = document.query_selector(&video_selector).ok().flatten()
340                        else {
341                            return;
342                        };
343                        let resolved: HtmlVideoElement = element.unchecked_into();
344                        *cache = Some(resolved.clone());
345                        resolved
346                    }
347                }
348            };
349            if video_element.ready_state() != HtmlMediaElement::HAVE_ENOUGH_DATA {
350                return;
351            }
352            let promise: Promise = match detect_fn.call1(&detector, &video_element) {
353                Ok(result) => result.into(),
354                Err(_) => return,
355            };
356            let _: Promise = promise.then(on_detected).catch(on_scan_error);
357        });
358        self_for_closure.get_scan_handle().set(Some(handle));
359    }
360
361    /// Stops the periodic QR code scan timer if it is running.
362    pub(crate) fn stop_qr_scan(self) {
363        if let Some(handle) = self.get_scan_handle().get() {
364            handle.clear();
365            self.get_scan_handle().set(None);
366        }
367        DETECT_FN_CACHE.with(|cache: &RefCell<Option<Function>>| {
368            *cache.borrow_mut() = None;
369        });
370    }
371
372    /// Checks whether the given string is a valid QR code URL that the
373    /// camera scanner should navigate to.
374    ///
375    /// A valid URL must start with `http://` or `https://`.
376    ///
377    /// # Arguments
378    ///
379    /// - `&str` - The string to check.
380    ///
381    /// # Returns
382    ///
383    /// - `bool` - `true` if the string is a valid HTTP or HTTPS URL.
384    pub(crate) fn is_valid_qr_url(text: &str) -> bool {
385        text.starts_with(CAMERA_URL_PREFIX_HTTP) || text.starts_with(CAMERA_URL_PREFIX_HTTPS)
386    }
387
388    /// Extracts the hostname from an absolute URL string using pure Rust
389    /// string parsing.
390    ///
391    /// Supports `http://` and `https://` schemes, strips IPv6 brackets,
392    /// and ignores the port portion. Returns an empty string if the URL
393    /// format is not recognised.
394    ///
395    /// # Arguments
396    ///
397    /// - `&str` - The absolute URL to parse.
398    ///
399    /// # Returns
400    ///
401    /// - `String` - The extracted hostname, or an empty string on failure.
402    pub(crate) fn extract_hostname(url: &str) -> String {
403        let rest: &str = if let Some(stripped) = url.strip_prefix(CAMERA_URL_PREFIX_HTTPS) {
404            stripped
405        } else if let Some(stripped) = url.strip_prefix(CAMERA_URL_PREFIX_HTTP) {
406            stripped
407        } else {
408            return String::new();
409        };
410        let authority: &str = rest.split('/').next().unwrap_or("");
411        let host_with_brackets: &str = authority.split(':').next().unwrap_or("");
412        if let Some(stripped) = host_with_brackets.strip_prefix('[')
413            && let Some(inner) = stripped.strip_suffix(']')
414        {
415            return inner.to_string();
416        }
417        host_with_brackets.to_string()
418    }
419
420    /// Checks whether the given hostname is a private or loopback IP
421    /// address.
422    ///
423    /// Recognises loopback (`127.0.0.0/8`), link-local (`169.254.0.0/16`),
424    /// RFC 1918 private ranges (`10.0.0.0/8`, `172.16.0.0/12`,
425    /// `192.168.0.0/16`), and the `localhost` hostname.
426    ///
427    /// # Arguments
428    ///
429    /// - `&str` - The hostname to inspect.
430    ///
431    /// # Returns
432    ///
433    /// - `bool` - `true` if the hostname is a private/internal address.
434    pub(crate) fn is_private_host(hostname: &str) -> bool {
435        if hostname.is_empty() {
436            return false;
437        }
438        if hostname.eq_ignore_ascii_case(CAMERA_LOCALHOST_HOSTNAME) {
439            return true;
440        }
441        let octets: Vec<&str> = hostname.split('.').collect();
442        if octets.len() != 4 {
443            return false;
444        }
445        let Ok(first) = octets[0].parse::<u8>() else {
446            return false;
447        };
448        let Ok(second) = octets[1].parse::<u8>() else {
449            return false;
450        };
451        if first == 127 {
452            return true;
453        }
454        if first == 10 {
455            return true;
456        }
457        if first == 172 && (16..=31).contains(&second) {
458            return true;
459        }
460        if first == 192 && second == 168 {
461            return true;
462        }
463        if first == 169 && second == 254 {
464            return true;
465        }
466        false
467    }
468
469    /// Navigates to the URL detected from a QR code.
470    ///
471    /// If the URL points to the same origin (current host), extracts the
472    /// hash fragment route and navigates internally using `navigate`.
473    /// If the URL host is a private/internal IP address, performs a full
474    /// page navigation via `location.href` within the current browser.
475    /// Otherwise (external public URL), opens the link in the system
476    /// browser via `window.open` so the user stays in the app.
477    ///
478    /// # Arguments
479    ///
480    /// - `&str` - The URL to navigate to.
481    pub(crate) fn navigate_qr_url(url: &str) {
482        let Some(window_value) = window() else {
483            return;
484        };
485        let location: Location = window_value.location();
486        let current_hostname: String = location.hostname().unwrap_or_default();
487        let url_hostname: String = Self::extract_hostname(url);
488        if url_hostname == current_hostname
489            && let Some(fragment) = url.split('#').nth(1)
490        {
491            let route: &str = if fragment.is_empty() { "/" } else { fragment };
492            Router::navigate(route);
493            return;
494        }
495        if Self::is_private_host(&url_hostname) {
496            let _: Result<(), JsValue> = window_value.location().set_href(url);
497            return;
498        }
499        if let Ok(open_fn) = Reflect::get(&window_value, &JsValue::from_str("open"))
500            .and_then(|value: JsValue| value.dyn_into::<Function>())
501        {
502            let _: Result<JsValue, JsValue> = open_fn.call2(
503                &window_value,
504                &JsValue::from_str(url),
505                &JsValue::from_str(SYSTEM_BROWSER_TARGET),
506            );
507        }
508    }
509
510    /// Creates a click event handler that closes the camera stream and
511    /// stops the QR code scan.
512    ///
513    /// # Arguments
514    ///
515    /// - `Option<&EuvCameraConfig>` - Optional camera configuration.
516    ///
517    /// # Returns
518    ///
519    /// - `Option<Rc<dyn Fn(Event)>>` - A click event handler.
520    pub fn on_close(self, config: Option<&EuvCameraConfig>) -> Option<Rc<dyn Fn(Event)>> {
521        let cfg: EuvCameraConfig =
522            config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
523        Some(Rc::new(move |_: Event| {
524            self.stop_qr_scan();
525            Self::close(cfg.video_selector);
526            self.get_camera_open().set(false);
527            self.get_scan_result().set(String::new());
528        }))
529    }
530
531    /// Creates a click event handler that switches the camera facing direction.
532    ///
533    /// # Arguments
534    ///
535    /// - `Option<&EuvCameraConfig>` - Optional camera configuration.
536    ///
537    /// # Returns
538    ///
539    /// - `Option<Rc<dyn Fn(Event)>>` - A click event handler.
540    pub fn on_switch(self, config: Option<&EuvCameraConfig>) -> Option<Rc<dyn Fn(Event)>> {
541        let cfg: EuvCameraConfig =
542            config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
543        Some(Rc::new(move |_: Event| {
544            self.switch(Some(&cfg));
545        }))
546    }
547
548    /// Creates a click event handler that opens the camera and starts QR scanning.
549    ///
550    /// # Arguments
551    ///
552    /// - `Option<&EuvCameraConfig>` - Optional camera configuration.
553    ///
554    /// # Returns
555    ///
556    /// - `Option<Rc<dyn Fn(Event)>>` - A click event handler.
557    pub fn on_open(self, config: Option<&EuvCameraConfig>) -> Option<Rc<dyn Fn(Event)>> {
558        let cfg: EuvCameraConfig =
559            config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
560        Some(Rc::new(move |_: Event| {
561            self.open_and_scan(Some(&cfg));
562        }))
563    }
564
565    /// Registers a cleanup callback that closes the camera stream and
566    /// stops the QR code scan timer when the component unmounts or
567    /// the page route switches away.
568    ///
569    /// # Arguments
570    ///
571    /// - `Option<&EuvCameraConfig>` - Optional camera configuration.
572    pub fn cleanup(self, config: Option<&EuvCameraConfig>) {
573        let cfg: EuvCameraConfig =
574            config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
575        App::use_cleanup(move || {
576            self.stop_qr_scan();
577            Self::close(cfg.video_selector);
578            self.get_camera_open().set(false);
579            self.get_camera_loading().set(false);
580            self.get_error_message().set(String::new());
581            self.get_scan_result().set(String::new());
582        });
583    }
584}
585
586/// Default implementation for `EuvCameraConfig`.
587impl Default for EuvCameraConfig {
588    /// Constructs a default [`EuvCameraConfig`] value.
589    fn default() -> Self {
590        EuvCameraConfig {
591            video_selector: CAMERA_VIDEO_SELECTOR,
592            scan_interval_millis: CAMERA_SCAN_INTERVAL_MILLIS,
593            auto_scan: true,
594            on_qr_detected: None,
595            on_error: None,
596        }
597    }
598}