1use std::sync::Arc;
22use tokio::sync::RwLock;
23use serde::Deserialize;
24use crate::socket::server::{Server, ServerMechanism, SerializationKey};
25
26
27pub enum PageTemplate {
41 Default {
59 title: Option<String>,
62 body_text: Option<String>,
65 },
66
67 Tickbox {
81 title: Option<String>,
84 body_text: Option<String>,
87 consent_text: Option<String>,
90 },
91
92 Custom(String),
113}
114
115impl Default for PageTemplate {
116 fn default() -> Self {
119 PageTemplate::Default {
120 title: None,
121 body_text: None,
122 }
123 }
124}
125
126#[derive(Debug, Clone, PartialEq)]
137pub struct LocationData {
138 pub latitude: f64,
140 pub longitude: f64,
142 pub accuracy: f64,
144 pub altitude: Option<f64>,
146 pub altitude_accuracy: Option<f64>,
148 pub heading: Option<f64>,
151 pub speed: Option<f64>,
153 pub timestamp_ms: f64,
156}
157
158#[derive(Debug, Clone, PartialEq, Eq)]
160pub enum LocationError {
161 PermissionDenied,
164 PositionUnavailable,
167 Timeout,
170 ServerError,
172}
173
174impl std::fmt::Display for LocationError {
175 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176 match self {
177 Self::PermissionDenied => write!(f, "location permission denied"),
178 Self::PositionUnavailable => write!(f, "position unavailable"),
179 Self::Timeout => write!(f, "location request timed out"),
180 Self::ServerError => write!(f, "internal server error"),
181 }
182 }
183}
184
185impl std::error::Error for LocationError {}
186
187#[derive(Deserialize, bincode::Encode, bincode::Decode)]
188struct BrowserLocationBody {
189 latitude: f64,
190 longitude: f64,
191 accuracy: f64,
192 altitude: Option<f64>,
193 altitude_accuracy: Option<f64>,
194 heading: Option<f64>,
195 speed: Option<f64>,
196 timestamp: f64,
197}
198
199#[derive(Deserialize, bincode::Encode, bincode::Decode)]
200struct BrowserErrorBody {
201 code: u32,
202 #[allow(dead_code)]
203 message: String,
204}
205
206struct GeoState {
211 result: Option<Result<LocationData, LocationError>>,
212 shutdown: Option<tokio::sync::oneshot::Sender<()>>,
213}
214
215
216pub fn __location__(template: PageTemplate) -> Result<LocationData, LocationError> {
265 match tokio::runtime::Handle::try_current() {
270 Ok(_) => {
271 let (tx, rx) = std::sync::mpsc::channel();
272 std::thread::spawn(move || {
273 let result = tokio::runtime::Builder::new_current_thread()
274 .enable_all()
275 .build()
276 .map_err(|_| LocationError::ServerError)
277 .and_then(|rt| rt.block_on(capture(template)));
278 let _ = tx.send(result);
279 });
280 rx.recv().unwrap_or(Err(LocationError::ServerError))
281 }
282 Err(_) => {
283 tokio::runtime::Builder::new_current_thread()
284 .enable_all()
285 .build()
286 .map_err(|_| LocationError::ServerError)?
287 .block_on(capture(template))
288 }
289 }
290}
291
292pub async fn __location_async__(template: PageTemplate) -> Result<LocationData, LocationError> {
311 capture(template).await
312}
313
314async fn capture(template: PageTemplate) -> Result<LocationData, LocationError> {
319 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
322 .await
323 .map_err(|_| LocationError::ServerError)?;
324
325 let port = listener
326 .local_addr()
327 .map_err(|_| LocationError::ServerError)?
328 .port();
329
330 use rand::Rng as _;
331 let mut rng = rand::rng();
332 let key: String = (0..16).map(|_| format!("{:02x}", rng.random::<u8>())).collect();
333 let skey = SerializationKey::Value(key.clone());
334
335 let html = render_page(&template, &key);
336
337 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
339 let state: Arc<RwLock<GeoState>> = Arc::new(RwLock::new(GeoState {
340 result: None,
341 shutdown: Some(shutdown_tx),
342 }));
343
344 let get_route = ServerMechanism::get("/").onconnect(move || {
346 let html = html.clone();
347 async move { Ok::<_, warp::Rejection>(warp::reply::html(html)) }
348 });
349
350 let post_route = ServerMechanism::post("/location")
352 .state(Arc::clone(&state))
353 .encryption::<BrowserLocationBody>(skey.clone())
354 .onconnect(
355 |s: Arc<RwLock<GeoState>>, body: BrowserLocationBody| async move {
356 let mut lock = s.write().await;
357 if lock.result.is_none() {
358 lock.result = Some(Ok(LocationData {
359 latitude: body.latitude,
360 longitude: body.longitude,
361 accuracy: body.accuracy,
362 altitude: body.altitude,
363 altitude_accuracy: body.altitude_accuracy,
364 heading: body.heading,
365 speed: body.speed,
366 timestamp_ms: body.timestamp,
367 }));
368 if let Some(tx) = lock.shutdown.take() {
369 let _ = tx.send(());
370 }
371 }
372 Ok::<_, warp::Rejection>(warp::reply())
373 },
374 );
375
376 let error_route = ServerMechanism::post("/location-error")
378 .state(Arc::clone(&state))
379 .encryption::<BrowserErrorBody>(skey)
380 .onconnect(
381 |s: Arc<RwLock<GeoState>>, body: BrowserErrorBody| async move {
382 let mut lock = s.write().await;
383 if lock.result.is_none() {
384 lock.result = Some(Err(match body.code {
385 1 => LocationError::PermissionDenied,
386 2 => LocationError::PositionUnavailable,
387 _ => LocationError::Timeout,
388 }));
389 if let Some(tx) = lock.shutdown.take() {
390 let _ = tx.send(());
391 }
392 }
393 Ok::<_, warp::Rejection>(warp::reply())
394 },
395 );
396
397 let mut server = Server::default();
398 server
399 .mechanism(get_route)
400 .mechanism(post_route)
401 .mechanism(error_route);
402
403 let url = format!("http://127.0.0.1:{port}");
405 if webbrowser::open(&url).is_err() {
406 eprintln!("Could not open browser automatically. Navigate to: {url}");
407 } else {
408 println!("Location capture page opened. If the browser did not appear, navigate to: {url}");
409 }
410
411 server
412 .serve_from_listener(listener, async move {
413 shutdown_rx.await.ok();
414 })
415 .await;
416
417 state
418 .write()
419 .await
420 .result
421 .take()
422 .unwrap_or(Err(LocationError::ServerError))
423}
424
425const CAPTURE_BUTTON: &str =
431 r#"<button id="btn" onclick="requestLocation()">Share My Location</button>"#;
432
433const CAPTURE_JS: &str = r#"<script>
439 var done = false;
440 function setStatus(msg) {
441 var el = document.getElementById('status');
442 if (el) el.textContent = msg;
443 }
444 function requestLocation() {
445 if (done) return;
446 document.getElementById('btn').disabled = true;
447 setStatus('Requesting location\u2026');
448 navigator.geolocation.getCurrentPosition(
449 function(pos) {
450 if (done) return; done = true;
451 var c = pos.coords;
452 fetch('/location', {
453 method: 'POST',
454 headers: { 'Content-Type': 'application/octet-stream' },
455 body: window.sealLocation({
456 latitude: c.latitude,
457 longitude: c.longitude,
458 accuracy: c.accuracy,
459 altitude: c.altitude,
460 altitude_accuracy: c.altitudeAccuracy,
461 heading: c.heading,
462 speed: c.speed,
463 timestamp: pos.timestamp
464 })
465 }).then(function() {
466 setStatus('\u2705 Location captured \u2014 you may close this tab.');
467 }).catch(function() {
468 setStatus('\u26a0\ufe0f Captured but could not reach the app.');
469 });
470 },
471 function(err) {
472 if (done) return; done = true;
473 fetch('/location-error', {
474 method: 'POST',
475 headers: { 'Content-Type': 'application/octet-stream' },
476 body: window.sealLocationError({ code: err.code, message: err.message })
477 }).then(function() {
478 setStatus('\u274c ' + err.message + '. You may close this tab.');
479 });
480 },
481 { enableHighAccuracy: true, timeout: 30000, maximumAge: 0 }
482 );
483 }
484</script>"#;
485
486const SHARED_CSS: &str = r#"<style>
488 *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
489 body {
490 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
491 display: flex; align-items: center; justify-content: center;
492 min-height: 100vh; background: #f5f5f7; color: #1d1d1f;
493 }
494 .card {
495 background: #fff; border-radius: 18px; padding: 44px 52px;
496 box-shadow: 0 4px 32px rgba(0,0,0,.10); max-width: 440px; width: 92%;
497 text-align: center;
498 }
499 .icon { font-size: 3rem; margin-bottom: 16px; }
500 h1 { font-size: 1.55rem; font-weight: 600; margin-bottom: 10px; }
501 p { font-size: .95rem; color: #555; line-height: 1.6; margin-bottom: 30px; }
502 button {
503 background: #0071e3; color: #fff; border: none; border-radius: 980px;
504 padding: 14px 34px; font-size: 1rem; cursor: pointer;
505 transition: background .15s, opacity .15s;
506 }
507 button:hover:not(:disabled) { background: #0077ed; }
508 button:disabled { opacity: .55; cursor: default; }
509 #status { margin-top: 22px; font-size: .88rem; color: #444; min-height: 1.3em; }
510 .consent-row {
511 display: flex; align-items: center; justify-content: center;
512 gap: 8px; margin-bottom: 24px; font-size: .92rem; color: #333;
513 }
514 .consent-row input[type="checkbox"] { width: 16px; height: 16px; cursor: pointer; }
515</style>"#;
516
517const TICKBOX_TOGGLE_JS: &str = r#"<script>
520 function toggleBtn() {
521 document.getElementById('btn').disabled =
522 !document.getElementById('consent').checked;
523 }
524</script>"#;
525
526fn veil_cipher_script(key: &str) -> String {
540 let kc: String = key.bytes().map(|b| b.to_string()).collect::<Vec<_>>().join(",");
541 format!(r#"<script>(function(){{
542var _K=[{kc}].map(function(c){{return String.fromCharCode(c);}}).join('');
543function _sm(x){{
544 x=BigInt.asUintN(64,x+0x9e3779b97f4a7c15n);
545 x=BigInt.asUintN(64,(x^(x>>30n))*0xbf58476d1ce4e5b9n);
546 x=BigInt.asUintN(64,(x^(x>>27n))*0x94d049bb133111ebn);
547 return x^(x>>31n);
548}}
549function _fnv(b){{
550 var h=0xcbf29ce484222325n,P=0x100000001b3n;
551 for(var i=0;i<b.length;i++) h=BigInt.asUintN(64,(h^BigInt(b[i]))*P);
552 return h;}}
553function _ks(k){{
554 var kb=new TextEncoder().encode(k);
555 var h0=_fnv(kb);
556 var h1=_sm(BigInt.asUintN(64,h0^0xdeadbeefcafebaben));
557 var sx=new Uint8Array(256);
558 for(var i=0;i<256;i++) sx[i]=i;
559 var r=h0;
560 for(var i=255;i>=1;i--){{
561 r=_sm(r);
562 var j=Number(r%BigInt(i+1));
563 var t=sx[i];sx[i]=sx[j];sx[j]=t;
564 }}
565 var ss=h1;
566 var sh=_sm(BigInt.asUintN(64,h1^0x1234567890abcdefn));
567 function sb(pos){{
568 var s=BigInt.asUintN(64,BigInt.asUintN(64,ss+BigInt(pos))*0x9e3779b97f4a7c15n);
569 var v=_sm(s);
570 return Number((v>>32n)^(v&0xffffffffn))&0xff;
571 }}
572 function pm(bi,len){{
573 var sd=BigInt.asUintN(64,BigInt.asUintN(64,sh+BigInt(bi))*0x6c62272e07bb0142n);
574 var p=[];
575 for(var i=0;i<len;i++) p.push(i);
576 var rr=_sm(sd);
577 for(var i=len-1;i>=1;i--){{
578 rr=_sm(rr);
579 var j=Number(rr%BigInt(i+1));
580 var t=p[i];p[i]=p[j];p[j]=t;
581 }}
582 return p;
583 }}
584 return{{sx:sx,sb:sb,pm:pm}};
585}}
586function _enc(plain){{
587 var ks=_ks(_K),b=new Uint8Array(plain.length);
588 for(var i=0;i<plain.length;i++) b[i]=ks.sx[plain[i]];
589 for(var i=0;i<b.length;i++) b[i]^=ks.sb(i);
590 var prev=0xA7;
591 for(var i=0;i<b.length;i++){{
592 var orig=b[i];
593 var mix=((((i&0xff)+prev)&0xff)*0x6b)&0xff;
594 b[i]=(b[i]^mix)&0xff;
595 prev=orig;
596 }}
597 var acc=0xB3;
598 for(var i=0;i<b.length;i++){{
599 var out=(b[i]^acc)&0xff;
600 acc=(((acc<<3)|(acc>>5))^out)&0xff;
601 b[i]=out;
602 }}
603 var BK=16,n=b.length,full=Math.floor(n/BK);
604 for(var bi=0;bi<full;bi++){{
605 var p=ks.pm(bi,BK),base=bi*BK,blk=b.slice(base,base+BK);
606 for(var i=0;i<BK;i++) b[base+i]=blk[p[i]];
607 }}
608 var ts=full*BK,tl=n-ts;
609 if(tl>1){{
610 var p=ks.pm(full,tl),tail=b.slice(ts);
611 for(var i=0;i<tl;i++) b[ts+i]=tail[p[i]];
612 }}
613 return b;
614}}
615var _f64b=new ArrayBuffer(8),_f64v=new DataView(_f64b);
616function _f64(v){{_f64v.setFloat64(0,v,true);return new Uint8Array(_f64b.slice(0));}}
617function _cat(arrays){{
618 var len=0,off=0;
619 for(var i=0;i<arrays.length;i++) len+=arrays[i].length;
620 var out=new Uint8Array(len);
621 for(var i=0;i<arrays.length;i++){{out.set(arrays[i],off);off+=arrays[i].length;}}
622 return out;
623}}
624function _opt(v){{
625 return(v===null||v===undefined)?new Uint8Array([0]):_cat([new Uint8Array([1]),_f64(v)]);
626}}
627function _vi(n){{
628 if(n<251) return new Uint8Array([n]);
629 if(n<65536) return new Uint8Array([251,n&0xff,(n>>8)&0xff]);
630 if(n<4294967296) return new Uint8Array([252,n&0xff,(n>>8)&0xff,(n>>16)&0xff,(n>>24)&0xff]);
631 var bn=BigInt(n);
632 return new Uint8Array([253,Number(bn&0xffn),Number((bn>>8n)&0xffn),Number((bn>>16n)&0xffn),
633 Number((bn>>24n)&0xffn),Number((bn>>32n)&0xffn),Number((bn>>40n)&0xffn),
634 Number((bn>>48n)&0xffn),Number((bn>>56n)&0xffn)]);
635}}
636function _wrap(b){{return _cat([_vi(b.length),b]);}}
637window.sealLocation=function(d){{
638 return _wrap(_enc(_cat([
639 _f64(d.latitude),_f64(d.longitude),_f64(d.accuracy),
640 _opt(d.altitude),_opt(d.altitude_accuracy),_opt(d.heading),_opt(d.speed),
641 _f64(d.timestamp)
642 ])));
643}};
644window.sealLocationError=function(d){{
645 var mb=new TextEncoder().encode(d.message);
646 return _wrap(_enc(_cat([_vi(d.code),_vi(mb.length),mb])));
647}};
648}})();</script>"#, kc = kc)
649}
650
651fn render_page(template: &PageTemplate, key: &str) -> String {
652 match template {
653 PageTemplate::Default { title, body_text } => {
654 let title = title.as_deref().unwrap_or("Location Access");
655 let body = body_text.as_deref().unwrap_or(
656 "An application on this computer is requesting your geographic \
657 location. Click <strong>Share My Location</strong> and allow \
658 access when the browser asks.",
659 );
660 let veil_js = veil_cipher_script(key);
661 format!(
662 r#"<!DOCTYPE html>
663<html lang="en">
664<head>
665 <meta charset="UTF-8">
666 <meta name="viewport" content="width=device-width, initial-scale=1.0">
667 <title>{title}</title>
668 {SHARED_CSS}
669</head>
670<body>
671 <div class="card">
672 <div class="icon">📍</div>
673 <h1>{title}</h1>
674 <p>{body}</p>
675 {CAPTURE_BUTTON}
676 <div id="status"></div>
677 </div>
678 {veil_js}
679 {CAPTURE_JS}
680</body>
681</html>"#
682 )
683 }
684
685 PageTemplate::Tickbox { title, body_text, consent_text } => {
686 let title = title.as_deref().unwrap_or("Location Access");
687 let body = body_text.as_deref().unwrap_or(
688 "An application on this computer is requesting your geographic \
689 location. Tick the box below, then click \
690 <strong>Share My Location</strong> to continue.",
691 );
692 let consent = consent_text.as_deref()
693 .unwrap_or("I consent to sharing my location.");
694 let tickbox_button =
696 r#"<button id="btn" onclick="requestLocation()" disabled>Share My Location</button>"#;
697 let veil_js = veil_cipher_script(key);
698 format!(
699 r#"<!DOCTYPE html>
700<html lang="en">
701<head>
702 <meta charset="UTF-8">
703 <meta name="viewport" content="width=device-width, initial-scale=1.0">
704 <title>{title}</title>
705 {SHARED_CSS}
706</head>
707<body>
708 <div class="card">
709 <div class="icon">📍</div>
710 <h1>{title}</h1>
711 <p>{body}</p>
712 <div class="consent-row">
713 <input type="checkbox" id="consent" onchange="toggleBtn()">
714 <label for="consent">{consent}</label>
715 </div>
716 {tickbox_button}
717 <div id="status"></div>
718 </div>
719 {TICKBOX_TOGGLE_JS}
720 {veil_js}
721 {CAPTURE_JS}
722</body>
723</html>"#
724 )
725 }
726
727 PageTemplate::Custom(html) => {
728 let with_button = html.replacen("{}", CAPTURE_BUTTON, 1);
730 let veil_js = veil_cipher_script(key);
735 let inject = format!("{veil_js}\n{CAPTURE_JS}\n</body>");
736 if with_button.contains("</body>") {
737 with_button.replacen("</body>", &inject, 1)
738 } else {
739 format!("{with_button}\n{veil_js}\n{CAPTURE_JS}")
740 }
741 }
742 }
743}