euv_ui/component/camera/hook/
impl.rs1use super::*;
2
3thread_local! {
4 static DETECT_FN_CACHE: RefCell<Option<Function>> = const { RefCell::new(None) };
11}
12
13impl UseEuvCamera {
15 pub fn use_camera_state() -> UseEuvCamera {
21 UseEuvCamera::new(
22 App::use_signal(|| false),
23 App::use_signal(|| false),
24 App::use_signal(String::new),
25 App::use_signal(EuvCameraFacing::default),
26 App::use_signal(String::new),
27 App::use_signal(|| None),
28 )
29 }
30
31 pub(crate) fn open(video_selector: &str, facing: EuvCameraFacing) -> Result<(), String> {
48 let Some(window_value) = window() else {
49 return Err(CAMERA_NO_WINDOW_ERROR.to_string());
50 };
51 let navigator: Navigator = window_value.navigator();
52 let media_devices: MediaDevices = navigator
53 .media_devices()
54 .map_err(|error: JsValue| format!("{error:?}"))?;
55 let constraints: MediaStreamConstraints = MediaStreamConstraints::new();
56 let facing_mode: &str = match facing {
57 EuvCameraFacing::User => CAMERA_FACING_MODE_USER,
58 EuvCameraFacing::Environment => CAMERA_FACING_MODE_ENVIRONMENT,
59 };
60 let video_constraint: Object = Object::new();
61 let _: Result<bool, JsValue> = Reflect::set(
62 &video_constraint,
63 &JsValue::from_str(CAMERA_FACING_MODE_KEY),
64 &JsValue::from_str(facing_mode),
65 );
66 constraints.set_video(&video_constraint);
67 constraints.set_audio(&JsValue::from_bool(false));
68 let promise: Promise = media_devices
69 .get_user_media_with_constraints(&constraints)
70 .map_err(|error: JsValue| format!("{error:?}"))?;
71 let selector: String = video_selector.to_string();
72 let on_fulfilled: Closure<dyn FnMut(JsValue)> =
73 Closure::wrap(Box::new(move |stream_value: JsValue| {
74 let stream: MediaStream = stream_value.unchecked_into();
75 let Some(window_value) = window() else {
76 return;
77 };
78 let Some(document) = window_value.document() else {
79 return;
80 };
81 if let Some(element) = document.query_selector(&selector).ok().flatten() {
82 let video_element: HtmlVideoElement = element.unchecked_into();
83 video_element.set_src_object(Some(&stream));
84 let _: Result<Promise, JsValue> = video_element.play();
85 }
86 }));
87 let on_rejected: Closure<dyn FnMut(JsValue)> =
88 Closure::wrap(Box::new(move |error: JsValue| {
89 console::log_2(&JsValue::from_str(CAMERA_LOG_PREFIX), &error);
90 }));
91 let _: Promise = promise.then(&on_fulfilled).catch(&on_rejected);
92 on_fulfilled.forget();
93 on_rejected.forget();
94 Ok(())
95 }
96
97 pub(crate) fn close(video_selector: &str) {
107 let Some(window_value) = window() else {
108 return;
109 };
110 let Some(document) = window_value.document() else {
111 return;
112 };
113 if let Some(element) = document.query_selector(video_selector).ok().flatten() {
114 let video_element: HtmlVideoElement = element.unchecked_into();
115 if let Some(stream) = video_element.src_object() {
116 let stream: MediaStream = stream.unchecked_into();
117 let tracks: Array = stream.get_tracks();
118 for track_value in tracks.iter() {
119 let track: MediaStreamTrack = track_value.unchecked_into();
120 track.stop();
121 }
122 }
123 video_element.set_src_object(None);
124 }
125 }
126
127 pub(crate) fn open_and_scan(self, config: Option<&EuvCameraConfig>) {
136 let cfg: EuvCameraConfig =
137 config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
138 self.get_camera_loading().set(true);
139 self.get_error_message().set(String::new());
140 self.get_scan_result().set(String::new());
141 let facing: EuvCameraFacing = self.get_facing().get();
142 let result: Result<(), String> = Self::open(cfg.video_selector, facing);
143 match result {
144 Ok(()) => {
145 self.get_camera_open().set(true);
146 self.get_camera_loading().set(false);
147 if cfg.auto_scan {
148 self.start_qr_scan(config);
149 }
150 }
151 Err(error) => {
152 self.get_error_message().set(error);
153 self.get_camera_loading().set(false);
154 if let Some(ref on_error) = cfg.on_error {
155 on_error(self.get_error_message().get());
156 }
157 }
158 }
159 }
160
161 pub(crate) fn switch(self, config: Option<&EuvCameraConfig>) {
171 let cfg: EuvCameraConfig =
172 config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
173 self.stop_qr_scan();
174 Self::close(cfg.video_selector);
175 self.get_camera_open().set(false);
176 let new_facing: EuvCameraFacing = match self.get_facing().get() {
177 EuvCameraFacing::User => EuvCameraFacing::Environment,
178 EuvCameraFacing::Environment => EuvCameraFacing::User,
179 };
180 self.get_facing().set(new_facing);
181 self.get_camera_loading().set(true);
182 self.get_error_message().set(String::new());
183 let result: Result<(), String> = Self::open(cfg.video_selector, new_facing);
184 match result {
185 Ok(()) => {
186 self.get_camera_open().set(true);
187 self.get_camera_loading().set(false);
188 if cfg.auto_scan {
189 self.start_qr_scan(config);
190 }
191 }
192 Err(error) => {
193 self.get_error_message().set(error);
194 self.get_camera_loading().set(false);
195 }
196 }
197 }
198
199 fn cached_detect_fn(detector: &JsValue) -> Function {
216 DETECT_FN_CACHE.with(|cache: &RefCell<Option<Function>>| {
217 if let Some(function) = cache.borrow().as_ref() {
224 return function.clone();
225 }
226 let function: Function =
227 Reflect::get(detector, &JsValue::from_str(CAMERA_DETECT_FN_KEY))
228 .ok()
229 .and_then(|value: JsValue| value.dyn_into::<Function>().ok())
230 .unwrap_or_else(|| Function::new_no_args(CAMERA_DETECT_FALLBACK_BODY));
231 *cache.borrow_mut() = Some(function.clone());
232 function
233 })
234 }
235
236 pub(crate) fn start_qr_scan(self, config: Option<&EuvCameraConfig>) {
255 let cfg: EuvCameraConfig =
256 config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
257 let Some(window_value) = window() else {
258 return;
259 };
260 let barcode_detector_key: JsValue = JsValue::from_str(CAMERA_BARCODE_DETECTOR_KEY);
261 let barcode_detector_constructor: Function =
262 match Reflect::get(&window_value, &barcode_detector_key) {
263 Ok(value) if !value.is_undefined() && !value.is_null() => value.unchecked_into(),
264 _ => {
265 self.get_error_message()
266 .set(CAMERA_BARCODE_UNSUPPORTED_MESSAGE.to_string());
267 return;
268 }
269 };
270 let formats_array: Array = Array::new();
271 formats_array.push(&JsValue::from_str(CAMERA_BARCODE_FORMAT_QR_CODE));
272 let init_object: Object = Object::new();
273 let _: Result<bool, JsValue> = Reflect::set(
274 &init_object,
275 &JsValue::from_str(CAMERA_BARCODE_FORMATS_KEY),
276 &formats_array,
277 );
278 let args_array: Array = Array::new();
279 args_array.push(&init_object.into());
280 let detector: JsValue = match Reflect::construct(&barcode_detector_constructor, &args_array)
281 {
282 Ok(value) => value,
283 Err(error) => {
284 self.get_error_message()
285 .set(format!("Failed to create BarcodeDetector: {error:?}"));
286 return;
287 }
288 };
289 let video_selector: Rc<String> = Rc::new(cfg.video_selector.to_string());
290 let on_qr_detected: Option<QrDetectedCallback> = cfg.on_qr_detected.clone();
291 let self_for_closure: UseEuvCamera = self;
292 let video_selector_for_closure: Rc<String> = video_selector.clone();
293 let on_qr_detected_for_closure: Option<QrDetectedCallback> = on_qr_detected.clone();
294 let on_detected: Closure<dyn FnMut(JsValue)> =
295 Closure::wrap(Box::new(move |barcodes_value: JsValue| {
296 let barcodes: Array = match barcodes_value.dyn_into::<Array>() {
297 Ok(array) => array,
298 Err(_) => return,
299 };
300 if barcodes.length() == 0 {
301 return;
302 }
303 let text: Option<String> = barcodes.get(0).as_string().or_else(|| {
304 Reflect::get(
305 &barcodes.get(0),
306 &JsValue::from_str(CAMERA_BARCODE_RAW_VALUE_KEY),
307 )
308 .ok()
309 .and_then(|v: JsValue| v.as_string())
310 });
311 if let Some(text) = text {
312 self_for_closure.get_scan_result().set(text.clone());
313 if let Some(ref callback) = on_qr_detected_for_closure {
314 callback(&text);
315 }
316 if Self::is_valid_qr_url(&text) {
317 self_for_closure.stop_qr_scan();
318 Self::close(&video_selector_for_closure);
319 self_for_closure.get_camera_open().set(false);
320 Self::navigate_qr_url(&text);
321 }
322 }
323 }));
324 let on_scan_error: Closure<dyn FnMut(JsValue)> =
325 Closure::wrap(Box::new(move |_error: JsValue| {}));
326 let detect_fn: Function = Self::cached_detect_fn(&detector);
327 let video_element_cache: Rc<RefCell<Option<HtmlVideoElement>>> =
332 Rc::new(RefCell::new(None));
333 let handle: IntervalHandle = App::use_interval(cfg.scan_interval_millis, move || {
334 let on_detected: &Closure<dyn FnMut(JsValue)> = &on_detected;
335 let on_scan_error: &Closure<dyn FnMut(JsValue)> = &on_scan_error;
336 let detect_fn: &Function = &detect_fn;
337 let Some(window_value) = window() else {
338 return;
339 };
340 let Some(document) = window_value.document() else {
341 return;
342 };
343 let video_element: HtmlVideoElement = {
344 let cached: Option<HtmlVideoElement> = video_element_cache
352 .try_borrow()
353 .map(|cache: Ref<'_, Option<HtmlVideoElement>>| cache.clone())
354 .unwrap_or_default();
355 match cached {
356 Some(element) if element.is_connected() => element,
357 _ => {
358 let Some(element) = document.query_selector(&video_selector).ok().flatten()
359 else {
360 return;
361 };
362 let resolved: HtmlVideoElement = element.unchecked_into();
363 if let Ok(mut cache) = video_element_cache.try_borrow_mut() {
368 *cache = Some(resolved.clone());
369 }
370 resolved
371 }
372 }
373 };
374 if video_element.ready_state() != HtmlMediaElement::HAVE_ENOUGH_DATA {
375 return;
376 }
377 let promise: Promise = match detect_fn.call1(&detector, &video_element) {
378 Ok(result) => result.into(),
379 Err(_) => return,
380 };
381 let _: Promise = promise.then(on_detected).catch(on_scan_error);
382 });
383 self_for_closure.get_scan_handle().set(Some(handle));
384 }
385
386 pub(crate) fn stop_qr_scan(self) {
388 if let Some(handle) = self.get_scan_handle().get() {
389 handle.clear();
390 self.get_scan_handle().set(None);
391 }
392 DETECT_FN_CACHE.with(|cache: &RefCell<Option<Function>>| {
393 *cache.borrow_mut() = None;
394 });
395 }
396
397 pub(crate) fn is_valid_qr_url(text: &str) -> bool {
410 text.starts_with(CAMERA_URL_PREFIX_HTTP) || text.starts_with(CAMERA_URL_PREFIX_HTTPS)
411 }
412
413 pub(crate) fn extract_hostname(url: &str) -> String {
428 let rest: &str = if let Some(stripped) = url.strip_prefix(CAMERA_URL_PREFIX_HTTPS) {
429 stripped
430 } else if let Some(stripped) = url.strip_prefix(CAMERA_URL_PREFIX_HTTP) {
431 stripped
432 } else {
433 return String::new();
434 };
435 let authority: &str = rest.split('/').next().unwrap_or("");
436 let host_with_brackets: &str = authority.split(':').next().unwrap_or("");
437 if let Some(stripped) = host_with_brackets.strip_prefix('[')
438 && let Some(inner) = stripped.strip_suffix(']')
439 {
440 return inner.to_string();
441 }
442 host_with_brackets.to_string()
443 }
444
445 pub(crate) fn is_private_host(hostname: &str) -> bool {
460 if hostname.is_empty() {
461 return false;
462 }
463 if hostname.eq_ignore_ascii_case(CAMERA_LOCALHOST_HOSTNAME) {
464 return true;
465 }
466 let octets: Vec<&str> = hostname.split('.').collect();
467 if octets.len() != 4 {
468 return false;
469 }
470 let Ok(first) = octets[0].parse::<u8>() else {
471 return false;
472 };
473 let Ok(second) = octets[1].parse::<u8>() else {
474 return false;
475 };
476 if first == 127 {
477 return true;
478 }
479 if first == 10 {
480 return true;
481 }
482 if first == 172 && (16..=31).contains(&second) {
483 return true;
484 }
485 if first == 192 && second == 168 {
486 return true;
487 }
488 if first == 169 && second == 254 {
489 return true;
490 }
491 false
492 }
493
494 pub(crate) fn navigate_qr_url(url: &str) {
507 let Some(window_value) = window() else {
508 return;
509 };
510 let location: Location = window_value.location();
511 let current_hostname: String = location.hostname().unwrap_or_default();
512 let url_hostname: String = Self::extract_hostname(url);
513 if url_hostname == current_hostname
514 && let Some(fragment) = url.split('#').nth(1)
515 {
516 let route: &str = if fragment.is_empty() { "/" } else { fragment };
517 Router::navigate(route);
518 return;
519 }
520 if Self::is_private_host(&url_hostname) {
521 let _: Result<(), JsValue> = window_value.location().set_href(url);
522 return;
523 }
524 if let Ok(open_fn) = Reflect::get(&window_value, &JsValue::from_str(CAMERA_WINDOW_OPEN_KEY))
525 .and_then(|value: JsValue| value.dyn_into::<Function>())
526 {
527 let _: Result<JsValue, JsValue> = open_fn.call2(
528 &window_value,
529 &JsValue::from_str(url),
530 &JsValue::from_str(SYSTEM_BROWSER_TARGET),
531 );
532 }
533 }
534
535 pub fn on_close(self, config: Option<&EuvCameraConfig>) -> Option<Rc<dyn Fn(Event)>> {
546 let cfg: EuvCameraConfig =
547 config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
548 Some(Rc::new(move |_: Event| {
549 self.stop_qr_scan();
550 Self::close(cfg.video_selector);
551 self.get_camera_open().set(false);
552 self.get_scan_result().set(String::new());
553 }))
554 }
555
556 pub fn on_switch(self, config: Option<&EuvCameraConfig>) -> Option<Rc<dyn Fn(Event)>> {
566 let cfg: EuvCameraConfig =
567 config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
568 Some(Rc::new(move |_: Event| {
569 self.switch(Some(&cfg));
570 }))
571 }
572
573 pub fn on_open(self, config: Option<&EuvCameraConfig>) -> Option<Rc<dyn Fn(Event)>> {
583 let cfg: EuvCameraConfig =
584 config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
585 Some(Rc::new(move |_: Event| {
586 self.open_and_scan(Some(&cfg));
587 }))
588 }
589
590 pub fn cleanup(self, config: Option<&EuvCameraConfig>) {
598 let cfg: EuvCameraConfig =
599 config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
600 App::use_cleanup(move || {
601 self.stop_qr_scan();
602 Self::close(cfg.video_selector);
603 self.get_camera_open().set(false);
604 self.get_camera_loading().set(false);
605 self.get_error_message().set(String::new());
606 self.get_scan_result().set(String::new());
607 });
608 }
609}
610
611impl Default for EuvCameraConfig {
613 fn default() -> Self {
615 EuvCameraConfig {
616 video_selector: CAMERA_VIDEO_SELECTOR,
617 scan_interval_millis: CAMERA_SCAN_INTERVAL_MILLIS,
618 auto_scan: true,
619 on_qr_detected: None,
620 on_error: None,
621 }
622 }
623}