euv_ui/component/camera/hook/
impl.rs1use super::*;
2use std::cell::RefCell;
3
4thread_local! {
5 static DETECT_FN_CACHE: RefCell<Option<Function>> = const { RefCell::new(None) };
12}
13
14const DETECT_FN_KEY: &str = "detect";
15
16impl UseEuvCamera {
18 pub fn use_camera_state() -> UseEuvCamera {
24 UseEuvCamera::new(
25 App::use_signal(|| false),
26 App::use_signal(|| false),
27 App::use_signal(String::new),
28 App::use_signal(EuvCameraFacing::default),
29 App::use_signal(String::new),
30 App::use_signal(|| None),
31 )
32 }
33
34 pub(crate) fn open(video_selector: &str, facing: EuvCameraFacing) -> Result<(), String> {
51 let Some(window_value) = window() else {
52 return Err("no global window exists".to_string());
53 };
54 let navigator: Navigator = window_value.navigator();
55 let media_devices: MediaDevices = navigator
56 .media_devices()
57 .map_err(|error: JsValue| format!("{error:?}"))?;
58 let constraints: MediaStreamConstraints = MediaStreamConstraints::new();
59 let facing_mode: &str = match facing {
60 EuvCameraFacing::User => CAMERA_FACING_MODE_USER,
61 EuvCameraFacing::Environment => CAMERA_FACING_MODE_ENVIRONMENT,
62 };
63 let video_constraint: Object = Object::new();
64 let _: Result<bool, JsValue> = Reflect::set(
65 &video_constraint,
66 &JsValue::from_str("facingMode"),
67 &JsValue::from_str(facing_mode),
68 );
69 constraints.set_video(&video_constraint);
70 constraints.set_audio(&JsValue::from_bool(false));
71 let promise: Promise = media_devices
72 .get_user_media_with_constraints(&constraints)
73 .map_err(|error: JsValue| format!("{error:?}"))?;
74 let selector: String = video_selector.to_string();
75 let on_fulfilled: Closure<dyn FnMut(JsValue)> =
76 Closure::wrap(Box::new(move |stream_value: JsValue| {
77 let stream: MediaStream = stream_value.unchecked_into();
78 let Some(window_value) = window() else {
79 return;
80 };
81 let Some(document) = window_value.document() else {
82 return;
83 };
84 if let Some(element) = document.query_selector(&selector).ok().flatten() {
85 let video_element: HtmlVideoElement = element.unchecked_into();
86 video_element.set_src_object(Some(&stream));
87 let _: Result<Promise, JsValue> = video_element.play();
88 }
89 }));
90 let on_rejected: Closure<dyn FnMut(JsValue)> =
91 Closure::wrap(Box::new(move |error: JsValue| {
92 web_sys::console::log_2(&wasm_bindgen::JsValue::from_str("[euv-camera]"), &error);
93 }));
94 let _: Promise = promise.then(&on_fulfilled).catch(&on_rejected);
95 on_fulfilled.forget();
96 on_rejected.forget();
97 Ok(())
98 }
99
100 pub(crate) fn close(video_selector: &str) {
110 let Some(window_value) = window() else {
111 return;
112 };
113 let Some(document) = window_value.document() else {
114 return;
115 };
116 if let Some(element) = document.query_selector(video_selector).ok().flatten() {
117 let video_element: HtmlVideoElement = element.unchecked_into();
118 if let Some(stream) = video_element.src_object() {
119 let stream: MediaStream = stream.unchecked_into();
120 let tracks: Array = stream.get_tracks();
121 for track_value in tracks.iter() {
122 let track: MediaStreamTrack = track_value.unchecked_into();
123 track.stop();
124 }
125 }
126 video_element.set_src_object(None);
127 }
128 }
129
130 pub(crate) fn open_and_scan(self, config: Option<&EuvCameraConfig>) {
139 let cfg: EuvCameraConfig =
140 config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
141 self.get_camera_loading().set(true);
142 self.get_error_message().set(String::new());
143 self.get_scan_result().set(String::new());
144 let facing: EuvCameraFacing = self.get_facing().get();
145 let result: Result<(), String> = Self::open(cfg.video_selector, facing);
146 match result {
147 Ok(()) => {
148 self.get_camera_open().set(true);
149 self.get_camera_loading().set(false);
150 if cfg.auto_scan {
151 self.start_qr_scan(config);
152 }
153 }
154 Err(error) => {
155 self.get_error_message().set(error);
156 self.get_camera_loading().set(false);
157 if let Some(ref on_error) = cfg.on_error {
158 on_error(self.get_error_message().get());
159 }
160 }
161 }
162 }
163
164 pub(crate) fn switch(self, config: Option<&EuvCameraConfig>) {
174 let cfg: EuvCameraConfig =
175 config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
176 self.stop_qr_scan();
177 Self::close(cfg.video_selector);
178 self.get_camera_open().set(false);
179 let new_facing: EuvCameraFacing = match self.get_facing().get() {
180 EuvCameraFacing::User => EuvCameraFacing::Environment,
181 EuvCameraFacing::Environment => EuvCameraFacing::User,
182 };
183 self.get_facing().set(new_facing);
184 self.get_camera_loading().set(true);
185 self.get_error_message().set(String::new());
186 let result: Result<(), String> = Self::open(cfg.video_selector, new_facing);
187 match result {
188 Ok(()) => {
189 self.get_camera_open().set(true);
190 self.get_camera_loading().set(false);
191 if cfg.auto_scan {
192 self.start_qr_scan(config);
193 }
194 }
195 Err(error) => {
196 self.get_error_message().set(error);
197 self.get_camera_loading().set(false);
198 }
199 }
200 }
201
202 fn cached_detect_fn(detector: &JsValue) -> Function {
219 DETECT_FN_CACHE.with(|cache: &RefCell<Option<Function>>| {
220 if let Some(function) = cache.borrow().as_ref() {
221 return function.clone();
222 }
223 let function: Function = Reflect::get(detector, &JsValue::from_str(DETECT_FN_KEY))
224 .ok()
225 .and_then(|value: JsValue| value.dyn_into::<Function>().ok())
226 .unwrap_or_else(|| Function::new_no_args("return Promise.resolve([])"));
227 *cache.borrow_mut() = Some(function.clone());
228 function
229 })
230 }
231
232 pub(crate) fn start_qr_scan(self, config: Option<&EuvCameraConfig>) {
251 let cfg: EuvCameraConfig =
252 config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
253 let Some(window_value) = window() else {
254 return;
255 };
256 let barcode_detector_key: JsValue = JsValue::from_str("BarcodeDetector");
257 let barcode_detector_constructor: Function =
258 match Reflect::get(&window_value, &barcode_detector_key) {
259 Ok(value) if !value.is_undefined() && !value.is_null() => value.unchecked_into(),
260 _ => {
261 self.get_error_message()
262 .set("BarcodeDetector API is not supported in this browser".to_string());
263 return;
264 }
265 };
266 let formats_array: Array = Array::new();
267 formats_array.push(&JsValue::from_str("qr_code"));
268 let init_object: Object = Object::new();
269 let _: Result<bool, JsValue> =
270 Reflect::set(&init_object, &JsValue::from_str("formats"), &formats_array);
271 let args_array: Array = Array::new();
272 args_array.push(&init_object.into());
273 let detector: JsValue = match Reflect::construct(&barcode_detector_constructor, &args_array)
274 {
275 Ok(value) => value,
276 Err(error) => {
277 self.get_error_message()
278 .set(format!("Failed to create BarcodeDetector: {error:?}"));
279 return;
280 }
281 };
282 let video_selector: Rc<String> = Rc::new(cfg.video_selector.to_string());
283 let on_qr_detected: Option<QrDetectedCallback> = cfg.on_qr_detected.clone();
284 let self_for_closure: UseEuvCamera = self;
285 let video_selector_for_closure: Rc<String> = video_selector.clone();
286 let on_qr_detected_for_closure: Option<QrDetectedCallback> = on_qr_detected.clone();
287 let on_detected: Closure<dyn FnMut(JsValue)> =
288 Closure::wrap(Box::new(move |barcodes_value: JsValue| {
289 let barcodes: Array = match barcodes_value.dyn_into::<Array>() {
290 Ok(array) => array,
291 Err(_) => return,
292 };
293 if barcodes.length() == 0 {
294 return;
295 }
296 let text: Option<String> = barcodes.get(0).as_string().or_else(|| {
297 Reflect::get(&barcodes.get(0), &JsValue::from_str("rawValue"))
298 .ok()
299 .and_then(|v: JsValue| v.as_string())
300 });
301 if let Some(text) = text {
302 self_for_closure.get_scan_result().set(text.clone());
303 if let Some(ref callback) = on_qr_detected_for_closure {
304 callback(&text);
305 }
306 if Self::is_valid_qr_url(&text) {
307 self_for_closure.stop_qr_scan();
308 Self::close(&video_selector_for_closure);
309 self_for_closure.get_camera_open().set(false);
310 Self::navigate_qr_url(&text);
311 }
312 }
313 }));
314 let on_scan_error: Closure<dyn FnMut(JsValue)> =
315 Closure::wrap(Box::new(move |_error: JsValue| {}));
316 let detect_fn: Function = Self::cached_detect_fn(&detector);
317 let handle: IntervalHandle = App::use_interval(cfg.scan_interval_millis, move || {
318 let on_detected: &Closure<dyn FnMut(JsValue)> = &on_detected;
319 let on_scan_error: &Closure<dyn FnMut(JsValue)> = &on_scan_error;
320 let detect_fn: &Function = &detect_fn;
321 let Some(window_value) = window() else {
322 return;
323 };
324 let Some(document) = window_value.document() else {
325 return;
326 };
327 let Some(element) = document.query_selector(&video_selector).ok().flatten() else {
328 return;
329 };
330 let video_element: HtmlVideoElement = element.unchecked_into();
331 if video_element.ready_state() != HtmlMediaElement::HAVE_ENOUGH_DATA {
332 return;
333 }
334 let promise: Promise = match detect_fn.call1(&detector, &video_element) {
335 Ok(result) => result.into(),
336 Err(_) => return,
337 };
338 let _: Promise = promise.then(on_detected).catch(on_scan_error);
339 });
340 self_for_closure.get_scan_handle().set(Some(handle));
341 }
342
343 pub(crate) fn stop_qr_scan(self) {
345 if let Some(handle) = self.get_scan_handle().get() {
346 handle.clear();
347 self.get_scan_handle().set(None);
348 }
349 DETECT_FN_CACHE.with(|cache: &RefCell<Option<Function>>| {
350 *cache.borrow_mut() = None;
351 });
352 }
353
354 pub(crate) fn is_valid_qr_url(text: &str) -> bool {
367 text.starts_with(CAMERA_URL_PREFIX_HTTP) || text.starts_with(CAMERA_URL_PREFIX_HTTPS)
368 }
369
370 pub(crate) fn extract_hostname(url: &str) -> String {
385 let rest: &str = if let Some(stripped) = url.strip_prefix(CAMERA_URL_PREFIX_HTTPS) {
386 stripped
387 } else if let Some(stripped) = url.strip_prefix(CAMERA_URL_PREFIX_HTTP) {
388 stripped
389 } else {
390 return String::new();
391 };
392 let authority: &str = rest.split('/').next().unwrap_or("");
393 let host_with_brackets: &str = authority.split(':').next().unwrap_or("");
394 if let Some(stripped) = host_with_brackets.strip_prefix('[')
395 && let Some(inner) = stripped.strip_suffix(']')
396 {
397 return inner.to_string();
398 }
399 host_with_brackets.to_string()
400 }
401
402 pub(crate) fn is_private_host(hostname: &str) -> bool {
417 if hostname.is_empty() {
418 return false;
419 }
420 if hostname.eq_ignore_ascii_case(CAMERA_LOCALHOST_HOSTNAME) {
421 return true;
422 }
423 let octets: Vec<&str> = hostname.split('.').collect();
424 if octets.len() != 4 {
425 return false;
426 }
427 let Ok(first) = octets[0].parse::<u8>() else {
428 return false;
429 };
430 let Ok(second) = octets[1].parse::<u8>() else {
431 return false;
432 };
433 if first == 127 {
434 return true;
435 }
436 if first == 10 {
437 return true;
438 }
439 if first == 172 && (16..=31).contains(&second) {
440 return true;
441 }
442 if first == 192 && second == 168 {
443 return true;
444 }
445 if first == 169 && second == 254 {
446 return true;
447 }
448 false
449 }
450
451 pub(crate) fn navigate_qr_url(url: &str) {
464 let Some(window_value) = window() else {
465 return;
466 };
467 let location: Location = window_value.location();
468 let current_hostname: String = location.hostname().unwrap_or_default();
469 let url_hostname: String = Self::extract_hostname(url);
470 if url_hostname == current_hostname
471 && let Some(fragment) = url.split('#').nth(1)
472 {
473 let route: &str = if fragment.is_empty() { "/" } else { fragment };
474 Router::navigate(route);
475 return;
476 }
477 if Self::is_private_host(&url_hostname) {
478 let _: Result<(), JsValue> = window_value.location().set_href(url);
479 return;
480 }
481 if let Ok(open_fn) = Reflect::get(&window_value, &JsValue::from_str("open"))
482 .and_then(|value: JsValue| value.dyn_into::<Function>())
483 {
484 let _: Result<JsValue, JsValue> = open_fn.call2(
485 &window_value,
486 &JsValue::from_str(url),
487 &JsValue::from_str(SYSTEM_BROWSER_TARGET),
488 );
489 }
490 }
491
492 pub fn on_close(self, config: Option<&EuvCameraConfig>) -> Option<Rc<dyn Fn(Event)>> {
503 let cfg: EuvCameraConfig =
504 config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
505 Some(Rc::new(move |_: Event| {
506 self.stop_qr_scan();
507 Self::close(cfg.video_selector);
508 self.get_camera_open().set(false);
509 self.get_scan_result().set(String::new());
510 }))
511 }
512
513 pub fn on_switch(self, config: Option<&EuvCameraConfig>) -> Option<Rc<dyn Fn(Event)>> {
523 let cfg: EuvCameraConfig =
524 config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
525 Some(Rc::new(move |_: Event| {
526 self.switch(Some(&cfg));
527 }))
528 }
529
530 pub fn on_open(self, config: Option<&EuvCameraConfig>) -> Option<Rc<dyn Fn(Event)>> {
540 let cfg: EuvCameraConfig =
541 config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
542 Some(Rc::new(move |_: Event| {
543 self.open_and_scan(Some(&cfg));
544 }))
545 }
546
547 pub fn cleanup(self, config: Option<&EuvCameraConfig>) {
555 let cfg: EuvCameraConfig =
556 config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
557 App::use_cleanup(move || {
558 self.stop_qr_scan();
559 Self::close(cfg.video_selector);
560 self.get_camera_open().set(false);
561 self.get_camera_loading().set(false);
562 self.get_error_message().set(String::new());
563 self.get_scan_result().set(String::new());
564 });
565 }
566}
567
568impl Default for EuvCameraConfig {
570 fn default() -> Self {
572 EuvCameraConfig {
573 video_selector: CAMERA_VIDEO_SELECTOR,
574 scan_interval_millis: CAMERA_SCAN_INTERVAL_MILLIS,
575 auto_scan: true,
576 on_qr_detected: None,
577 on_error: None,
578 }
579 }
580}