Skip to main content

euv_ui/component/camera/hook/
impl.rs

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