use std::sync::Arc;
use tokio::sync::RwLock;
use serde::Deserialize;
use crate::socket::server::{Server, ServerMechanism, SerializationKey, Rejection, EmptyReply, html_reply};
const CHACHA20POLY1305_WASM: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/chacha20poly1305.wasm"));
pub enum PageTemplate {
Default {
title: Option<String>,
body_text: Option<String>,
},
Tickbox {
title: Option<String>,
body_text: Option<String>,
consent_text: Option<String>,
},
Custom(String),
}
impl Default for PageTemplate {
fn default() -> Self {
PageTemplate::Default {
title: None,
body_text: None,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct LocationData {
pub latitude: f64,
pub longitude: f64,
pub accuracy: f64,
pub altitude: Option<f64>,
pub altitude_accuracy: Option<f64>,
pub heading: Option<f64>,
pub speed: Option<f64>,
pub timestamp_ms: f64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum LocationError {
PermissionDenied,
PositionUnavailable,
Timeout,
ServerError,
}
impl std::fmt::Display for LocationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::PermissionDenied => write!(f, "location permission denied"),
Self::PositionUnavailable => write!(f, "position unavailable"),
Self::Timeout => write!(f, "location request timed out"),
Self::ServerError => write!(f, "internal server error"),
}
}
}
impl std::error::Error for LocationError {}
#[derive(Deserialize, bincode::Encode, bincode::Decode)]
struct BrowserLocationBody {
latitude: f64,
longitude: f64,
accuracy: f64,
altitude: Option<f64>,
altitude_accuracy: Option<f64>,
heading: Option<f64>,
speed: Option<f64>,
timestamp: f64,
}
#[derive(Deserialize, bincode::Encode, bincode::Decode)]
struct BrowserErrorBody {
code: u32,
#[allow(dead_code)]
message: String,
}
struct GeoState {
result: Option<Result<LocationData, LocationError>>,
shutdown: Option<tokio::sync::oneshot::Sender<()>>,
}
pub fn __location__(template: PageTemplate) -> Result<LocationData, LocationError> {
match tokio::runtime::Handle::try_current() {
Ok(_) => {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let result = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|_| LocationError::ServerError)
.and_then(|rt| rt.block_on(capture(template)));
let _ = tx.send(result);
});
rx.recv().unwrap_or(Err(LocationError::ServerError))
}
Err(_) => {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|_| LocationError::ServerError)?
.block_on(capture(template))
}
}
}
pub async fn __location_async__(template: PageTemplate) -> Result<LocationData, LocationError> {
capture(template).await
}
async fn capture(template: PageTemplate) -> Result<LocationData, LocationError> {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.map_err(|_| LocationError::ServerError)?;
let port = listener
.local_addr()
.map_err(|_| LocationError::ServerError)?
.port();
use rand::Rng as _;
let mut rng = rand::rng();
let key: String = (0..16).map(|_| format!("{:02x}", rng.random::<u8>())).collect();
let skey = SerializationKey::Value(key.clone());
let html = render_page(&template, &key);
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
let state: Arc<RwLock<GeoState>> = Arc::new(RwLock::new(GeoState {
result: None,
shutdown: Some(shutdown_tx),
}));
let get_route = ServerMechanism::get("/").onconnect(move || {
let html = html.clone();
async move { Ok::<_, Rejection>(html_reply(html)) }
});
let post_route = ServerMechanism::post("/location")
.state(Arc::clone(&state))
.encryption::<BrowserLocationBody>(skey.clone())
.onconnect(
|s: Arc<RwLock<GeoState>>, body: BrowserLocationBody| async move {
let mut lock = s.write().await;
if lock.result.is_none() {
lock.result = Some(Ok(LocationData {
latitude: body.latitude,
longitude: body.longitude,
accuracy: body.accuracy,
altitude: body.altitude,
altitude_accuracy: body.altitude_accuracy,
heading: body.heading,
speed: body.speed,
timestamp_ms: body.timestamp,
}));
if let Some(tx) = lock.shutdown.take() {
let _ = tx.send(());
}
}
Ok::<_, Rejection>(EmptyReply)
},
);
let error_route = ServerMechanism::post("/location-error")
.state(Arc::clone(&state))
.encryption::<BrowserErrorBody>(skey)
.onconnect(
|s: Arc<RwLock<GeoState>>, body: BrowserErrorBody| async move {
let mut lock = s.write().await;
if lock.result.is_none() {
lock.result = Some(Err(match body.code {
1 => LocationError::PermissionDenied,
2 => LocationError::PositionUnavailable,
_ => LocationError::Timeout,
}));
if let Some(tx) = lock.shutdown.take() {
let _ = tx.send(());
}
}
Ok::<_, Rejection>(EmptyReply)
},
);
let mut server = Server::default();
server
.mechanism(get_route)
.mechanism(post_route)
.mechanism(error_route);
let url = format!("http://127.0.0.1:{port}");
if webbrowser::open(&url).is_err() {
log::warn!("Could not open browser automatically. Navigate to: {url}");
} else {
log::info!("Location capture page opened. If the browser did not appear, navigate to: {url}");
}
server
.serve_from_listener(listener, async move {
shutdown_rx.await.ok();
})
.await;
state
.write()
.await
.result
.take()
.unwrap_or(Err(LocationError::ServerError))
}
const CAPTURE_BUTTON: &str =
r#"<button id="btn" onclick="requestLocation()">Share My Location</button>"#;
const CAPTURE_JS: &str = r#"<script>
var done = false;
function setStatus(msg) {
var el = document.getElementById('status');
if (el) el.textContent = msg;
}
function requestLocation() {
if (done) return;
document.getElementById('btn').disabled = true;
setStatus('Requesting location\u2026');
navigator.geolocation.getCurrentPosition(
function(pos) {
if (done) return; done = true;
var c = pos.coords;
fetch('/location', {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: window.sealLocation({
latitude: c.latitude,
longitude: c.longitude,
accuracy: c.accuracy,
altitude: c.altitude,
altitude_accuracy: c.altitudeAccuracy,
heading: c.heading,
speed: c.speed,
timestamp: pos.timestamp
})
}).then(function() {
setStatus('\u2705 Location captured \u2014 you may close this tab.');
}).catch(function() {
setStatus('\u26a0\ufe0f Captured but could not reach the app.');
});
},
function(err) {
if (done) return; done = true;
fetch('/location-error', {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: window.sealLocationError({ code: err.code, message: err.message })
}).then(function() {
setStatus('\u274c ' + err.message + '. You may close this tab.');
});
},
{ enableHighAccuracy: true, timeout: 30000, maximumAge: 0 }
);
}
</script>"#;
const SHARED_CSS: &str = r#"<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex; align-items: center; justify-content: center;
min-height: 100vh; background: #f5f5f7; color: #1d1d1f;
}
.card {
background: #fff; border-radius: 18px; padding: 44px 52px;
box-shadow: 0 4px 32px rgba(0,0,0,.10); max-width: 440px; width: 92%;
text-align: center;
}
.icon { font-size: 3rem; margin-bottom: 16px; }
h1 { font-size: 1.55rem; font-weight: 600; margin-bottom: 10px; }
p { font-size: .95rem; color: #555; line-height: 1.6; margin-bottom: 30px; }
button {
background: #0071e3; color: #fff; border: none; border-radius: 980px;
padding: 14px 34px; font-size: 1rem; cursor: pointer;
transition: background .15s, opacity .15s;
}
button:hover:not(:disabled) { background: #0077ed; }
button:disabled { opacity: .55; cursor: default; }
#status { margin-top: 22px; font-size: .88rem; color: #444; min-height: 1.3em; }
.consent-row {
display: flex; align-items: center; justify-content: center;
gap: 8px; margin-bottom: 24px; font-size: .92rem; color: #333;
}
.consent-row input[type="checkbox"] { width: 16px; height: 16px; cursor: pointer; }
</style>"#;
const TICKBOX_TOGGLE_JS: &str = r#"<script>
function toggleBtn() {
document.getElementById('btn').disabled =
!document.getElementById('consent').checked;
}
</script>"#;
fn geo_seal_script(key: &str) -> String {
use base64::{engine::general_purpose::STANDARD, Engine as _};
let b64 = STANDARD.encode(CHACHA20POLY1305_WASM);
format!(r#"<script>(async function(){{
const wasmB64 = "{b64}";
const bytes = Uint8Array.from(atob(wasmB64), function(c){{ return c.charCodeAt(0); }});
const {{ instance }} = await WebAssembly.instantiate(bytes, {{}});
const wasm = instance.exports;
const keyStr = {key:?};
function f64le(v) {{
const ab = new ArrayBuffer(8);
new DataView(ab).setFloat64(0, v, true);
return new Uint8Array(ab);
}}
function optF64(v) {{
if (v == null) return new Uint8Array([0]);
const a = new Uint8Array(9); a[0] = 1;
a.set(f64le(v), 1); return a;
}}
function varint(n) {{
if (n < 251) return new Uint8Array([n]);
if (n < 65536) return new Uint8Array([251, n & 0xff, (n >> 8) & 0xff]);
const a = new Uint8Array(5); a[0] = 252;
for (let i = 0; i < 4; i++) a[1+i] = (n >> (i*8)) & 0xff; return a;
}}
function cat() {{
let len = 0;
for (let i = 0; i < arguments.length; i++) len += arguments[i].length;
const out = new Uint8Array(len); let off = 0;
for (let i = 0; i < arguments.length; i++) {{ out.set(arguments[i], off); off += arguments[i].length; }}
return out;
}}
function seal(plain) {{
const keyBytes = new TextEncoder().encode(keyStr);
const nonce = new Uint8Array(12);
crypto.getRandomValues(nonce);
const mem = new Uint8Array(wasm.memory.buffer);
mem.set(keyBytes, wasm.key_buf_ptr());
mem.set(nonce, wasm.nonce_buf_ptr());
mem.set(plain, wasm.plain_buf_ptr());
if (wasm.encrypt(keyBytes.length, plain.length) !== 0) throw new Error('seal failed');
const clen = wasm.cipher_len();
return new Uint8Array(wasm.memory.buffer, wasm.cipher_buf_ptr(), clen).slice();
}}
window.sealLocation = function(d) {{
return seal(cat(
f64le(d.latitude), f64le(d.longitude), f64le(d.accuracy),
optF64(d.altitude), optF64(d.altitude_accuracy),
optF64(d.heading), optF64(d.speed), f64le(d.timestamp)
));
}};
window.sealLocationError = function(d) {{
const mb = new TextEncoder().encode(d.message);
return seal(cat(varint(d.code), varint(mb.length), mb));
}};
}})();</script>"#)
}
fn render_page(template: &PageTemplate, key: &str) -> String {
match template {
PageTemplate::Default { title, body_text } => {
let title = title.as_deref().unwrap_or("Location Access");
let body = body_text.as_deref().unwrap_or(
"An application on this computer is requesting your geographic \
location. Click <strong>Share My Location</strong> and allow \
access when the browser asks.",
);
let seal_js = geo_seal_script(key);
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
{SHARED_CSS}
</head>
<body>
<div class="card">
<div class="icon">📍</div>
<h1>{title}</h1>
<p>{body}</p>
{CAPTURE_BUTTON}
<div id="status"></div>
</div>
{seal_js}
{CAPTURE_JS}
</body>
</html>"#
)
}
PageTemplate::Tickbox { title, body_text, consent_text } => {
let title = title.as_deref().unwrap_or("Location Access");
let body = body_text.as_deref().unwrap_or(
"An application on this computer is requesting your geographic \
location. Tick the box below, then click \
<strong>Share My Location</strong> to continue.",
);
let consent = consent_text.as_deref()
.unwrap_or("I consent to sharing my location.");
let tickbox_button =
r#"<button id="btn" onclick="requestLocation()" disabled>Share My Location</button>"#;
let seal_js = geo_seal_script(key);
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
{SHARED_CSS}
</head>
<body>
<div class="card">
<div class="icon">📍</div>
<h1>{title}</h1>
<p>{body}</p>
<div class="consent-row">
<input type="checkbox" id="consent" onchange="toggleBtn()">
<label for="consent">{consent}</label>
</div>
{tickbox_button}
<div id="status"></div>
</div>
{TICKBOX_TOGGLE_JS}
{seal_js}
{CAPTURE_JS}
</body>
</html>"#
)
}
PageTemplate::Custom(html) => {
let with_button = html.replacen("{}", CAPTURE_BUTTON, 1);
let seal_js = geo_seal_script(key);
let inject = format!("{seal_js}\n{CAPTURE_JS}\n</body>");
if with_button.contains("</body>") {
with_button.replacen("</body>", &inject, 1)
} else {
format!("{with_button}\n{seal_js}\n{CAPTURE_JS}")
}
}
}
}
pub use toolkit_zero_macros::browser;