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        if Reflect::get(&window_value, &barcode_detector_key).is_err() {
198            self.get_error_message()
199                .set("BarcodeDetector API is not supported in this browser".to_string());
200            return;
201        }
202        let detector_result: Result<JsValue, JsValue> =
203            Function::new_no_args("return new BarcodeDetector({ formats: ['qr_code'] })")
204                .call0(&JsValue::NULL);
205        let detector: JsValue = match detector_result {
206            Ok(value) => value,
207            Err(error) => {
208                self.get_error_message()
209                    .set(format!("Failed to create BarcodeDetector: {error:?}"));
210                return;
211            }
212        };
213        let video_selector: Rc<String> = Rc::new(cfg.video_selector.to_string());
214        let on_qr_detected: Option<QrDetectedCallback> = cfg.on_qr_detected.clone();
215        let handle: IntervalHandle = App::use_interval(cfg.scan_interval_millis, move || {
216            let document: Document = window()
217                .expect("no global window exists")
218                .document()
219                .expect("should have a document");
220            let Some(element) = document.query_selector(&video_selector).ok().flatten() else {
221                return;
222            };
223            let video_element: HtmlVideoElement = element.unchecked_into();
224            if video_element.ready_state() != HtmlMediaElement::HAVE_ENOUGH_DATA {
225                return;
226            }
227            let detect_fn: Function = Reflect::get(&detector, &JsValue::from_str("detect"))
228                .ok()
229                .and_then(|value: JsValue| value.dyn_into::<Function>().ok())
230                .unwrap_or_else(|| Function::new_no_args("return Promise.resolve([])"));
231            let promise: Promise = match detect_fn.call1(&detector, &video_element) {
232                Ok(result) => result.into(),
233                Err(_) => return,
234            };
235            let on_qr_detected_clone: Option<QrDetectedCallback> = on_qr_detected.clone();
236            let video_selector_clone: Rc<String> = video_selector.clone();
237            let on_detected: Closure<dyn FnMut(JsValue)> =
238                Closure::wrap(Box::new(move |barcodes_value: JsValue| {
239                    let barcodes: Array = match barcodes_value.dyn_into::<Array>() {
240                        Ok(array) => array,
241                        Err(_) => return,
242                    };
243                    if barcodes.length() == 0 {
244                        return;
245                    }
246                    let text: Option<String> = barcodes.get(0).as_string().or_else(|| {
247                        Reflect::get(&barcodes.get(0), &JsValue::from_str("rawValue"))
248                            .ok()
249                            .and_then(|v: JsValue| v.as_string())
250                    });
251                    if let Some(text) = text {
252                        self.get_scan_result().set(text.clone());
253                        if let Some(ref callback) = on_qr_detected_clone {
254                            callback(&text);
255                        }
256                        if Self::is_valid_qr_url(&text) {
257                            self.stop_qr_scan();
258                            Self::close(&video_selector_clone);
259                            self.get_camera_open().set(false);
260                            Self::navigate_qr_url(&text);
261                        }
262                    }
263                }));
264            let on_scan_error: Closure<dyn FnMut(JsValue)> =
265                Closure::wrap(Box::new(move |_error: JsValue| {}));
266            let _ = promise.then(&on_detected).catch(&on_scan_error);
267            on_detected.forget();
268            on_scan_error.forget();
269        });
270        self.get_scan_handle().set(Some(handle));
271    }
272
273    /// Stops the periodic QR code scan timer if it is running.
274    pub(crate) fn stop_qr_scan(self) {
275        if let Some(handle) = self.get_scan_handle().get() {
276            handle.clear();
277            self.get_scan_handle().set(None);
278        }
279    }
280
281    /// Checks whether the given string is a valid QR code URL that the
282    /// camera scanner should navigate to.
283    ///
284    /// A valid URL must start with `http://` or `https://`.
285    ///
286    /// # Arguments
287    ///
288    /// - `&str`: The string to check.
289    ///
290    /// # Returns
291    ///
292    /// - `bool`: `true` if the string is a valid HTTP or HTTPS URL.
293    pub(crate) fn is_valid_qr_url(text: &str) -> bool {
294        text.starts_with(CAMERA_URL_PREFIX_HTTP) || text.starts_with(CAMERA_URL_PREFIX_HTTPS)
295    }
296
297    /// Extracts the hostname from an absolute URL string using pure Rust
298    /// string parsing.
299    ///
300    /// Supports `http://` and `https://` schemes, strips IPv6 brackets,
301    /// and ignores the port portion. Returns an empty string if the URL
302    /// format is not recognised.
303    ///
304    /// # Arguments
305    ///
306    /// - `&str`: The absolute URL to parse.
307    ///
308    /// # Returns
309    ///
310    /// - `String`: The extracted hostname, or an empty string on failure.
311    pub(crate) fn extract_hostname(url: &str) -> String {
312        let rest: &str = if let Some(stripped) = url.strip_prefix(CAMERA_URL_PREFIX_HTTPS) {
313            stripped
314        } else if let Some(stripped) = url.strip_prefix(CAMERA_URL_PREFIX_HTTP) {
315            stripped
316        } else {
317            return String::new();
318        };
319        let authority: &str = rest.split('/').next().unwrap_or("");
320        let host_with_brackets: &str = authority.split(':').next().unwrap_or("");
321        if let Some(stripped) = host_with_brackets.strip_prefix('[')
322            && let Some(inner) = stripped.strip_suffix(']')
323        {
324            return inner.to_string();
325        }
326        host_with_brackets.to_string()
327    }
328
329    /// Checks whether the given hostname is a private or loopback IP
330    /// address.
331    ///
332    /// Recognises loopback (`127.0.0.0/8`), link-local (`169.254.0.0/16`),
333    /// RFC 1918 private ranges (`10.0.0.0/8`, `172.16.0.0/12`,
334    /// `192.168.0.0/16`), and the `localhost` hostname.
335    ///
336    /// # Arguments
337    ///
338    /// - `&str`: The hostname to inspect.
339    ///
340    /// # Returns
341    ///
342    /// - `bool`: `true` if the hostname is a private/internal address.
343    pub(crate) fn is_private_host(hostname: &str) -> bool {
344        if hostname.is_empty() {
345            return false;
346        }
347        if hostname.eq_ignore_ascii_case(CAMERA_LOCALHOST_HOSTNAME) {
348            return true;
349        }
350        let octets: Vec<&str> = hostname.split('.').collect();
351        if octets.len() != 4 {
352            return false;
353        }
354        let Ok(first) = octets[0].parse::<u8>() else {
355            return false;
356        };
357        let Ok(second) = octets[1].parse::<u8>() else {
358            return false;
359        };
360        if first == 127 {
361            return true;
362        }
363        if first == 10 {
364            return true;
365        }
366        if first == 172 && (16..=31).contains(&second) {
367            return true;
368        }
369        if first == 192 && second == 168 {
370            return true;
371        }
372        if first == 169 && second == 254 {
373            return true;
374        }
375        false
376    }
377
378    /// Navigates to the URL detected from a QR code.
379    ///
380    /// If the URL points to the same origin (current host), extracts the
381    /// hash fragment route and navigates internally using `navigate`.
382    /// If the URL host is a private/internal IP address, performs a full
383    /// page navigation via `location.href` within the current browser.
384    /// Otherwise (external public URL), opens the link in the system
385    /// browser via `window.open` so the user stays in the app.
386    ///
387    /// # Arguments
388    ///
389    /// - `&str`: The URL to navigate to.
390    pub(crate) fn navigate_qr_url(url: &str) {
391        let window_value: Window = window().expect("no global window exists");
392        let location: Location = window_value.location();
393        let current_hostname: String = location.hostname().unwrap_or_default();
394        let url_hostname: String = Self::extract_hostname(url);
395        if url_hostname == current_hostname
396            && let Some(fragment) = url.split('#').nth(1)
397        {
398            let route: &str = if fragment.is_empty() { "/" } else { fragment };
399            Router::navigate(route);
400            return;
401        }
402        if Self::is_private_host(&url_hostname) {
403            let _ = window_value.location().set_href(url);
404            return;
405        }
406        if let Ok(open_fn) = Reflect::get(&window_value, &JsValue::from_str("open"))
407            .and_then(|value: JsValue| value.dyn_into::<Function>())
408        {
409            let _ = open_fn.call2(
410                &window_value,
411                &JsValue::from_str(url),
412                &JsValue::from_str(SYSTEM_BROWSER_TARGET),
413            );
414        }
415    }
416
417    /// Creates a click event handler that closes the camera stream and
418    /// stops the QR code scan.
419    ///
420    /// # Arguments
421    ///
422    /// - `Option<&EuvCameraConfig>`: Optional camera configuration.
423    ///
424    /// # Returns
425    ///
426    /// - `Option<Rc<dyn Fn(Event)>>`: A click event handler.
427    pub fn on_close(self, config: Option<&EuvCameraConfig>) -> Option<Rc<dyn Fn(Event)>> {
428        let cfg: EuvCameraConfig =
429            config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
430        Some(Rc::new(move |_: Event| {
431            self.stop_qr_scan();
432            Self::close(cfg.video_selector);
433            self.get_camera_open().set(false);
434            self.get_scan_result().set(String::new());
435        }))
436    }
437
438    /// Creates a click event handler that switches the camera facing direction.
439    ///
440    /// # Arguments
441    ///
442    /// - `Option<&EuvCameraConfig>`: Optional camera configuration.
443    ///
444    /// # Returns
445    ///
446    /// - `Option<Rc<dyn Fn(Event)>>`: A click event handler.
447    pub fn on_switch(self, config: Option<&EuvCameraConfig>) -> Option<Rc<dyn Fn(Event)>> {
448        let cfg: EuvCameraConfig =
449            config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
450        Some(Rc::new(move |_: Event| {
451            self.switch(Some(&cfg));
452        }))
453    }
454
455    /// Creates a click event handler that opens the camera and starts QR scanning.
456    ///
457    /// # Arguments
458    ///
459    /// - `Option<&EuvCameraConfig>`: Optional camera configuration.
460    ///
461    /// # Returns
462    ///
463    /// - `Option<Rc<dyn Fn(Event)>>`: A click event handler.
464    pub fn on_open(self, config: Option<&EuvCameraConfig>) -> Option<Rc<dyn Fn(Event)>> {
465        let cfg: EuvCameraConfig =
466            config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
467        Some(Rc::new(move |_: Event| {
468            self.open_and_scan(Some(&cfg));
469        }))
470    }
471
472    /// Registers a cleanup callback that closes the camera stream and
473    /// stops the QR code scan timer when the component unmounts or
474    /// the page route switches away.
475    ///
476    /// # Arguments
477    ///
478    /// - `Option<&EuvCameraConfig>`: Optional camera configuration.
479    pub fn cleanup(self, config: Option<&EuvCameraConfig>) {
480        let cfg: EuvCameraConfig =
481            config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
482        App::use_cleanup(move || {
483            self.stop_qr_scan();
484            Self::close(cfg.video_selector);
485            self.get_camera_open().set(false);
486            self.get_camera_loading().set(false);
487            self.get_error_message().set(String::new());
488            self.get_scan_result().set(String::new());
489        });
490    }
491}
492
493/// Default implementation for `EuvCameraConfig`.
494impl Default for EuvCameraConfig {
495    fn default() -> Self {
496        EuvCameraConfig {
497            video_selector: CAMERA_VIDEO_SELECTOR,
498            scan_interval_millis: CAMERA_SCAN_INTERVAL_MILLIS,
499            auto_scan: false,
500            on_qr_detected: None,
501            on_error: None,
502        }
503    }
504}