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