Skip to main content

euv_ui/component/camera/hook/
impl.rs

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