toolkit_zero/location/browser.rs
1//! The most simplest of location mechanism that uses the native browser to
2//! fetch location info about current device. This module provides [`__location__`],
3//! a versatile function that works with both sync and async contexts.
4//!
5//! The browser will prompt the user for location permission upon acceptance,
6//! the location data will be returned back.
7//!
8//! 1. Binds a temporary HTTP server on a random `localhost` port using the
9//! [`socket-server`](crate::socket) module.
10//! 2. Opens the system's default browser to a consent page served by that
11//! server (using the [`webbrowser`] crate — works on macOS, Windows, and
12//! Linux).
13//! 3. The browser prompts the user for location permission via the standard
14//! [`navigator.geolocation.getCurrentPosition`][geo-api] Web API.
15//! 4. On success the browser POSTs the coordinates back to the local server,
16//! which shuts itself down and returns the data to the caller.
17//!
18//! [geo-api]: https://developer.mozilla.org/en-US/docs/Web/API/Geolocation/getCurrentPosition
19
20
21use std::sync::Arc;
22use tokio::sync::RwLock;
23use serde::Deserialize;
24use crate::socket::server::{Server, ServerMechanism, SerializationKey, Rejection, EmptyReply, html_reply};
25
26/// The compiled ChaCha20-Poly1305 WASM binary, embedded at build time.
27/// Used by [`geo_seal_script`] to inline real ChaCha20-Poly1305 sealing into
28/// every location consent page.
29const CHACHA20POLY1305_WASM: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/chacha20poly1305.wasm"));
30
31
32// ─────────────────────────────────────────────────────────────────────────────
33// Page template
34// ─────────────────────────────────────────────────────────────────────────────
35
36/// Controls what the user sees in the browser when [`__location__`] is called.
37///
38/// # Variants at a glance
39///
40/// | Variant | Description |
41/// |---|---|
42/// | [`PageTemplate::Default`] | Clean single-button page. Title and body text are customisable. |
43/// | [`PageTemplate::Tickbox`] | Same, but requires a checkbox tick before the button activates. Title, body text, and consent label are customisable. |
44/// | [`PageTemplate::Custom`] | Fully custom HTML. Place `{}` where the capture button should appear; the required JavaScript is injected automatically. |
45pub enum PageTemplate {
46 /// A clean, single-button consent page.
47 ///
48 /// Both fields fall back to sensible built-in values when `None`.
49 ///
50 /// # Example
51 ///
52 /// ```no_run
53 /// # use toolkit_zero::location::browser::PageTemplate;
54 /// // fully default
55 /// let t = PageTemplate::default();
56 ///
57 /// // custom title only
58 /// let t = PageTemplate::Default {
59 /// title: Some("My App — Location".into()),
60 /// body_text: None,
61 /// };
62 /// ```
63 Default {
64 /// Browser tab title and `<h1>` heading text.
65 /// Falls back to `"Location Access"` when `None`.
66 title: Option<String>,
67 /// Descriptive paragraph shown below the heading.
68 /// Falls back to a generic description when `None`.
69 body_text: Option<String>,
70 },
71
72 /// Like [`Default`](PageTemplate::Default) but adds a checkbox the user
73 /// must tick before the capture button becomes active.
74 ///
75 /// # Example
76 ///
77 /// ```no_run
78 /// # use toolkit_zero::location::browser::PageTemplate;
79 /// let t = PageTemplate::Tickbox {
80 /// title: Some("Verify Location".into()),
81 /// body_text: None,
82 /// consent_text: Some("I agree to share my location with this application.".into()),
83 /// };
84 /// ```
85 Tickbox {
86 /// Browser tab title and `<h1>` heading text.
87 /// Falls back to `"Location Access"` when `None`.
88 title: Option<String>,
89 /// Descriptive paragraph shown below the heading.
90 /// Falls back to a generic description when `None`.
91 body_text: Option<String>,
92 /// Text label shown next to the checkbox.
93 /// Falls back to `"I consent to sharing my location."` when `None`.
94 consent_text: Option<String>,
95 },
96
97 /// A fully custom HTML document.
98 ///
99 /// Place exactly one `{}` in the string where the capture button should
100 /// be injected. The required JavaScript (which POSTs to `/location` and
101 /// `/location-error`) is injected automatically before `</body>`, so you
102 /// do not need to write any geolocation JS yourself.
103 ///
104 /// # Example
105 ///
106 /// ```no_run
107 /// # use toolkit_zero::location::browser::PageTemplate;
108 /// let html = r#"<!DOCTYPE html>
109 /// <html><body>
110 /// <h1>Where are you?</h1>
111 /// {}
112 /// <div id="status"></div>
113 /// </body></html>"#;
114 ///
115 /// let t = PageTemplate::Custom(html.into());
116 /// ```
117 Custom(String),
118}
119
120impl Default for PageTemplate {
121 /// Returns [`PageTemplate::Default`] with both fields set to `None`
122 /// (all text falls back to built-in defaults).
123 fn default() -> Self {
124 PageTemplate::Default {
125 title: None,
126 body_text: None,
127 }
128 }
129}
130
131// ─────────────────────────────────────────────────────────────────────────────
132// Public data types
133// ─────────────────────────────────────────────────────────────────────────────
134
135/// Geographic coordinates returned by the browser [Geolocation API].
136///
137/// Fields that are optional in the spec are `None` when the browser or device
138/// did not supply them.
139///
140/// [Geolocation API]: https://developer.mozilla.org/en-US/docs/Web/API/GeolocationCoordinates
141#[derive(Debug, Clone, PartialEq)]
142pub struct LocationData {
143 /// Latitude in decimal degrees (WGS 84).
144 pub latitude: f64,
145 /// Longitude in decimal degrees (WGS 84).
146 pub longitude: f64,
147 /// Horizontal accuracy radius in metres (95 % confidence).
148 pub accuracy: f64,
149 /// Altitude in metres above the WGS 84 ellipsoid, if provided by the device.
150 pub altitude: Option<f64>,
151 /// Accuracy of [`altitude`](Self::altitude) in metres, if provided.
152 pub altitude_accuracy: Option<f64>,
153 /// Direction of travel clockwise from true north in degrees `[0, 360)`,
154 /// or `None` when the device is stationary or the sensor is unavailable.
155 pub heading: Option<f64>,
156 /// Ground speed in metres per second, or `None` when unavailable.
157 pub speed: Option<f64>,
158 /// Browser timestamp in milliseconds since the Unix epoch at which the
159 /// position was acquired.
160 pub timestamp_ms: f64,
161}
162
163/// Errors that can be returned by [`__location__`].
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub enum LocationError {
166 /// The user denied the browser's location permission prompt
167 /// (`GeolocationPositionError.PERMISSION_DENIED`, code 1).
168 PermissionDenied,
169 /// The device could not determine its position
170 /// (`GeolocationPositionError.POSITION_UNAVAILABLE`, code 2).
171 PositionUnavailable,
172 /// The browser did not obtain a fix within its internal 30 s timeout
173 /// (`GeolocationPositionError.TIMEOUT`, code 3).
174 Timeout,
175 /// The local HTTP server or the Tokio runtime could not be started.
176 ServerError,
177}
178
179impl std::fmt::Display for LocationError {
180 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
181 match self {
182 Self::PermissionDenied => write!(f, "location permission denied"),
183 Self::PositionUnavailable => write!(f, "position unavailable"),
184 Self::Timeout => write!(f, "location request timed out"),
185 Self::ServerError => write!(f, "internal server error"),
186 }
187 }
188}
189
190impl std::error::Error for LocationError {}
191
192#[derive(Deserialize, bincode::Encode, bincode::Decode)]
193struct BrowserLocationBody {
194 latitude: f64,
195 longitude: f64,
196 accuracy: f64,
197 altitude: Option<f64>,
198 altitude_accuracy: Option<f64>,
199 heading: Option<f64>,
200 speed: Option<f64>,
201 timestamp: f64,
202}
203
204#[derive(Deserialize, bincode::Encode, bincode::Decode)]
205struct BrowserErrorBody {
206 code: u32,
207 #[allow(dead_code)]
208 message: String,
209}
210
211// ─────────────────────────────────────────────────────────────────────────────
212// Shared in-flight state between route handlers
213// ─────────────────────────────────────────────────────────────────────────────
214
215struct GeoState {
216 result: Option<Result<LocationData, LocationError>>,
217 shutdown: Option<tokio::sync::oneshot::Sender<()>>,
218}
219
220
221/// Capture the user's geographic location by opening the browser to a local
222/// consent page.
223///
224/// This is a **blocking** call. It:
225///
226/// 1. Binds a temporary HTTP server on a free `127.0.0.1` port.
227/// 2. Opens `http://127.0.0.1:<port>/` in the system's default browser.
228/// 3. Waits for the browser to POST location data (or an error) back.
229/// 4. Shuts the server down and returns the result.
230///
231/// Pass a [`PageTemplate`] to control what the user sees in the browser.
232/// Use [`PageTemplate::default()`] for the built-in single-button consent page.
233///
234/// # Errors
235///
236/// | Variant | Cause |
237/// |---|---|
238/// | [`LocationError::PermissionDenied`] | User denied the browser prompt. |
239/// | [`LocationError::PositionUnavailable`] | Device cannot determine position. |
240/// | [`LocationError::Timeout`] | No fix within the browser's 30 s timeout. |
241/// | [`LocationError::ServerError`] | Failed to start the local HTTP server or runtime. |
242///
243/// # Example
244///
245/// ```no_run
246/// use toolkit_zero::location::browser::{__location__, PageTemplate, LocationError};
247///
248/// // Default built-in page
249/// match __location__(PageTemplate::default()) {
250/// Ok(data) => println!("lat={:.6} lon={:.6} ±{:.0}m",
251/// data.latitude, data.longitude, data.accuracy),
252/// Err(LocationError::PermissionDenied) => eprintln!("access denied"),
253/// Err(e) => eprintln!("error: {e}"),
254/// }
255///
256/// // Tickbox page with custom consent label
257/// let _ = __location__(PageTemplate::Tickbox {
258/// title: None,
259/// body_text: None,
260/// consent_text: Some("I agree to share my location.".into()),
261/// });
262/// ```
263///
264/// # Note on async callers
265///
266/// If you are already inside a Tokio async context (e.g. inside
267/// `#[tokio::main]`), use [`__location_async__`] instead — it is the raw
268/// `async fn` and avoids spawning an extra OS thread.
269pub fn __location__(template: PageTemplate) -> Result<LocationData, LocationError> {
270 // If called from within an existing Tokio runtime, calling `block_on` on
271 // that same thread would panic with "Cannot start a runtime from within a
272 // runtime". Detect this case and delegate to a dedicated OS thread that
273 // owns its own single-threaded runtime, then join the result back.
274 match tokio::runtime::Handle::try_current() {
275 Ok(_) => {
276 let (tx, rx) = std::sync::mpsc::channel();
277 std::thread::spawn(move || {
278 let result = tokio::runtime::Builder::new_current_thread()
279 .enable_all()
280 .build()
281 .map_err(|_| LocationError::ServerError)
282 .and_then(|rt| rt.block_on(capture(template)));
283 let _ = tx.send(result);
284 });
285 rx.recv().unwrap_or(Err(LocationError::ServerError))
286 }
287 Err(_) => {
288 tokio::runtime::Builder::new_current_thread()
289 .enable_all()
290 .build()
291 .map_err(|_| LocationError::ServerError)?
292 .block_on(capture(template))
293 }
294 }
295}
296
297/// Async version of [`__location__`].
298///
299/// Directly awaits the capture future — preferred when you are already running
300/// inside a Tokio async context so no extra OS thread needs to be spawned.
301///
302/// # Example
303///
304/// ```no_run
305/// use toolkit_zero::location::browser::{__location_async__, PageTemplate, LocationError};
306///
307/// #[tokio::main]
308/// async fn main() {
309/// match __location_async__(PageTemplate::default()).await {
310/// Ok(data) => println!("lat={:.6} lon={:.6}", data.latitude, data.longitude),
311/// Err(e) => eprintln!("error: {e}"),
312/// }
313/// }
314/// ```
315pub async fn __location_async__(template: PageTemplate) -> Result<LocationData, LocationError> {
316 capture(template).await
317}
318
319// ─────────────────────────────────────────────────────────────────────────────
320// Async core
321// ─────────────────────────────────────────────────────────────────────────────
322
323async fn capture(template: PageTemplate) -> Result<LocationData, LocationError> {
324 // Bind to port 0 so the OS assigns a free port; pass the ready listener
325 // directly to the server to avoid any TOCTOU race.
326 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
327 .await
328 .map_err(|_| LocationError::ServerError)?;
329
330 let port = listener
331 .local_addr()
332 .map_err(|_| LocationError::ServerError)?
333 .port();
334
335 use rand::Rng as _;
336 let mut rng = rand::rng();
337 let key: String = (0..16).map(|_| format!("{:02x}", rng.random::<u8>())).collect();
338 let skey = SerializationKey::Value(key.clone());
339
340 let html = render_page(&template, &key);
341
342 // Shared state written by POST handlers, read after the server exits.
343 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
344 let state: Arc<RwLock<GeoState>> = Arc::new(RwLock::new(GeoState {
345 result: None,
346 shutdown: Some(shutdown_tx),
347 }));
348
349 // ── GET / — serve the consent page ────────────────────────────────────
350 let get_route = ServerMechanism::get("/").onconnect(move || {
351 let html = html.clone();
352 async move { Ok::<_, Rejection>(html_reply(html)) }
353 });
354
355 // ── POST /location — browser geolocation success ───────────────────────
356 let post_route = ServerMechanism::post("/location")
357 .state(Arc::clone(&state))
358 .encryption::<BrowserLocationBody>(skey.clone())
359 .onconnect(
360 |s: Arc<RwLock<GeoState>>, body: BrowserLocationBody| async move {
361 let mut lock = s.write().await;
362 if lock.result.is_none() {
363 lock.result = Some(Ok(LocationData {
364 latitude: body.latitude,
365 longitude: body.longitude,
366 accuracy: body.accuracy,
367 altitude: body.altitude,
368 altitude_accuracy: body.altitude_accuracy,
369 heading: body.heading,
370 speed: body.speed,
371 timestamp_ms: body.timestamp,
372 }));
373 if let Some(tx) = lock.shutdown.take() {
374 let _ = tx.send(());
375 }
376 }
377 Ok::<_, Rejection>(EmptyReply)
378 },
379 );
380
381 // ── POST /location-error — browser geolocation failure ────────────────
382 let error_route = ServerMechanism::post("/location-error")
383 .state(Arc::clone(&state))
384 .encryption::<BrowserErrorBody>(skey)
385 .onconnect(
386 |s: Arc<RwLock<GeoState>>, body: BrowserErrorBody| async move {
387 let mut lock = s.write().await;
388 if lock.result.is_none() {
389 lock.result = Some(Err(match body.code {
390 1 => LocationError::PermissionDenied,
391 2 => LocationError::PositionUnavailable,
392 _ => LocationError::Timeout,
393 }));
394 if let Some(tx) = lock.shutdown.take() {
395 let _ = tx.send(());
396 }
397 }
398 Ok::<_, Rejection>(EmptyReply)
399 },
400 );
401
402 let mut server = Server::default();
403 server
404 .mechanism(get_route)
405 .mechanism(post_route)
406 .mechanism(error_route);
407
408 // Open the browser before blocking on the server.
409 let url = format!("http://127.0.0.1:{port}");
410 if webbrowser::open(&url).is_err() {
411 eprintln!("Could not open browser automatically. Navigate to: {url}");
412 } else {
413 println!("Location capture page opened. If the browser did not appear, navigate to: {url}");
414 }
415
416 server
417 .serve_from_listener(listener, async move {
418 shutdown_rx.await.ok();
419 })
420 .await;
421
422 state
423 .write()
424 .await
425 .result
426 .take()
427 .unwrap_or(Err(LocationError::ServerError))
428}
429
430// ─────────────────────────────────────────────────────────────────────────────
431// HTML rendering
432// ─────────────────────────────────────────────────────────────────────────────
433
434/// The capture button element injected into every template.
435const CAPTURE_BUTTON: &str =
436 r#"<button id="btn" onclick="requestLocation()">Share My Location</button>"#;
437
438/// JavaScript that handles geolocation and POSTs the ChaCha20-Poly1305-sealed
439/// result to the local server. Injected into every template (including `Custom`).
440/// Requires [`geo_seal_script`] to be present on the page so that
441/// `window.sealLocation` and `window.sealLocationError` are available.
442const CAPTURE_JS: &str = r#"<script>
443 var done = false;
444 function setStatus(msg) {
445 var el = document.getElementById('status');
446 if (el) el.textContent = msg;
447 }
448 function requestLocation() {
449 if (done) return;
450 document.getElementById('btn').disabled = true;
451 setStatus('Requesting location\u2026');
452 navigator.geolocation.getCurrentPosition(
453 function(pos) {
454 if (done) return; done = true;
455 var c = pos.coords;
456 fetch('/location', {
457 method: 'POST',
458 headers: { 'Content-Type': 'application/octet-stream' },
459 body: window.sealLocation({
460 latitude: c.latitude,
461 longitude: c.longitude,
462 accuracy: c.accuracy,
463 altitude: c.altitude,
464 altitude_accuracy: c.altitudeAccuracy,
465 heading: c.heading,
466 speed: c.speed,
467 timestamp: pos.timestamp
468 })
469 }).then(function() {
470 setStatus('\u2705 Location captured \u2014 you may close this tab.');
471 }).catch(function() {
472 setStatus('\u26a0\ufe0f Captured but could not reach the app.');
473 });
474 },
475 function(err) {
476 if (done) return; done = true;
477 fetch('/location-error', {
478 method: 'POST',
479 headers: { 'Content-Type': 'application/octet-stream' },
480 body: window.sealLocationError({ code: err.code, message: err.message })
481 }).then(function() {
482 setStatus('\u274c ' + err.message + '. You may close this tab.');
483 });
484 },
485 { enableHighAccuracy: true, timeout: 30000, maximumAge: 0 }
486 );
487 }
488</script>"#;
489
490/// Shared CSS used by the two built-in templates.
491const SHARED_CSS: &str = r#"<style>
492 *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
493 body {
494 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
495 display: flex; align-items: center; justify-content: center;
496 min-height: 100vh; background: #f5f5f7; color: #1d1d1f;
497 }
498 .card {
499 background: #fff; border-radius: 18px; padding: 44px 52px;
500 box-shadow: 0 4px 32px rgba(0,0,0,.10); max-width: 440px; width: 92%;
501 text-align: center;
502 }
503 .icon { font-size: 3rem; margin-bottom: 16px; }
504 h1 { font-size: 1.55rem; font-weight: 600; margin-bottom: 10px; }
505 p { font-size: .95rem; color: #555; line-height: 1.6; margin-bottom: 30px; }
506 button {
507 background: #0071e3; color: #fff; border: none; border-radius: 980px;
508 padding: 14px 34px; font-size: 1rem; cursor: pointer;
509 transition: background .15s, opacity .15s;
510 }
511 button:hover:not(:disabled) { background: #0077ed; }
512 button:disabled { opacity: .55; cursor: default; }
513 #status { margin-top: 22px; font-size: .88rem; color: #444; min-height: 1.3em; }
514 .consent-row {
515 display: flex; align-items: center; justify-content: center;
516 gap: 8px; margin-bottom: 24px; font-size: .92rem; color: #333;
517 }
518 .consent-row input[type="checkbox"] { width: 16px; height: 16px; cursor: pointer; }
519</style>"#;
520
521/// Helper for the tickbox template: a tiny inline script that enables /
522/// disables the button based on whether the checkbox is checked.
523const TICKBOX_TOGGLE_JS: &str = r#"<script>
524 function toggleBtn() {
525 document.getElementById('btn').disabled =
526 !document.getElementById('consent').checked;
527 }
528</script>"#;
529
530/// Generates a `<script>` block that loads the embedded ChaCha20-Poly1305
531/// WASM module and exposes two globals used by [`CAPTURE_JS`]:
532/// - `window.sealLocation(data)` — bincode-encodes and seals a location payload.
533/// - `window.sealLocationError(data)` — bincode-encodes and seals an error payload.
534///
535/// Both functions return a `Uint8Array` ready to POST as
536/// `application/octet-stream`. The output exactly matches
537/// [`crate::serialization::seal`] so the server's `.encryption::<T>(key)`
538/// route can decrypt them transparently.
539fn geo_seal_script(key: &str) -> String {
540 use base64::{engine::general_purpose::STANDARD, Engine as _};
541 let b64 = STANDARD.encode(CHACHA20POLY1305_WASM);
542 format!(r#"<script>(async function(){{
543 const wasmB64 = "{b64}";
544 const bytes = Uint8Array.from(atob(wasmB64), function(c){{ return c.charCodeAt(0); }});
545 const {{ instance }} = await WebAssembly.instantiate(bytes, {{}});
546 const wasm = instance.exports;
547 const keyStr = {key:?};
548
549 function f64le(v) {{
550 const ab = new ArrayBuffer(8);
551 new DataView(ab).setFloat64(0, v, true);
552 return new Uint8Array(ab);
553 }}
554 function optF64(v) {{
555 if (v == null) return new Uint8Array([0]);
556 const a = new Uint8Array(9); a[0] = 1;
557 a.set(f64le(v), 1); return a;
558 }}
559 function varint(n) {{
560 if (n < 251) return new Uint8Array([n]);
561 if (n < 65536) return new Uint8Array([251, n & 0xff, (n >> 8) & 0xff]);
562 const a = new Uint8Array(5); a[0] = 252;
563 for (let i = 0; i < 4; i++) a[1+i] = (n >> (i*8)) & 0xff; return a;
564 }}
565 function cat() {{
566 let len = 0;
567 for (let i = 0; i < arguments.length; i++) len += arguments[i].length;
568 const out = new Uint8Array(len); let off = 0;
569 for (let i = 0; i < arguments.length; i++) {{ out.set(arguments[i], off); off += arguments[i].length; }}
570 return out;
571 }}
572
573 function seal(plain) {{
574 const keyBytes = new TextEncoder().encode(keyStr);
575 const nonce = new Uint8Array(12);
576 crypto.getRandomValues(nonce);
577 const mem = new Uint8Array(wasm.memory.buffer);
578 mem.set(keyBytes, wasm.key_buf_ptr());
579 mem.set(nonce, wasm.nonce_buf_ptr());
580 mem.set(plain, wasm.plain_buf_ptr());
581 if (wasm.encrypt(keyBytes.length, plain.length) !== 0) throw new Error('seal failed');
582 const clen = wasm.cipher_len();
583 return new Uint8Array(wasm.memory.buffer, wasm.cipher_buf_ptr(), clen).slice();
584 }}
585
586 window.sealLocation = function(d) {{
587 return seal(cat(
588 f64le(d.latitude), f64le(d.longitude), f64le(d.accuracy),
589 optF64(d.altitude), optF64(d.altitude_accuracy),
590 optF64(d.heading), optF64(d.speed), f64le(d.timestamp)
591 ));
592 }};
593 window.sealLocationError = function(d) {{
594 const mb = new TextEncoder().encode(d.message);
595 return seal(cat(varint(d.code), varint(mb.length), mb));
596 }};
597}})();</script>"#)
598}
599
600fn render_page(template: &PageTemplate, key: &str) -> String {
601 match template {
602 PageTemplate::Default { title, body_text } => {
603 let title = title.as_deref().unwrap_or("Location Access");
604 let body = body_text.as_deref().unwrap_or(
605 "An application on this computer is requesting your geographic \
606 location. Click <strong>Share My Location</strong> and allow \
607 access when the browser asks.",
608 );
609 let seal_js = geo_seal_script(key);
610 format!(
611 r#"<!DOCTYPE html>
612<html lang="en">
613<head>
614 <meta charset="UTF-8">
615 <meta name="viewport" content="width=device-width, initial-scale=1.0">
616 <title>{title}</title>
617 {SHARED_CSS}
618</head>
619<body>
620 <div class="card">
621 <div class="icon">📍</div>
622 <h1>{title}</h1>
623 <p>{body}</p>
624 {CAPTURE_BUTTON}
625 <div id="status"></div>
626 </div>
627 {seal_js}
628 {CAPTURE_JS}
629</body>
630</html>"#
631 )
632 }
633
634 PageTemplate::Tickbox { title, body_text, consent_text } => {
635 let title = title.as_deref().unwrap_or("Location Access");
636 let body = body_text.as_deref().unwrap_or(
637 "An application on this computer is requesting your geographic \
638 location. Tick the box below, then click \
639 <strong>Share My Location</strong> to continue.",
640 );
641 let consent = consent_text.as_deref()
642 .unwrap_or("I consent to sharing my location.");
643 // The capture button is disabled by default; toggleBtn() enables it.
644 let tickbox_button =
645 r#"<button id="btn" onclick="requestLocation()" disabled>Share My Location</button>"#;
646 let seal_js = geo_seal_script(key);
647 format!(
648 r#"<!DOCTYPE html>
649<html lang="en">
650<head>
651 <meta charset="UTF-8">
652 <meta name="viewport" content="width=device-width, initial-scale=1.0">
653 <title>{title}</title>
654 {SHARED_CSS}
655</head>
656<body>
657 <div class="card">
658 <div class="icon">📍</div>
659 <h1>{title}</h1>
660 <p>{body}</p>
661 <div class="consent-row">
662 <input type="checkbox" id="consent" onchange="toggleBtn()">
663 <label for="consent">{consent}</label>
664 </div>
665 {tickbox_button}
666 <div id="status"></div>
667 </div>
668 {TICKBOX_TOGGLE_JS}
669 {seal_js}
670 {CAPTURE_JS}
671</body>
672</html>"#
673 )
674 }
675
676 PageTemplate::Custom(html) => {
677 // Replace the first {} with the capture button.
678 let with_button = html.replacen("{}", CAPTURE_BUTTON, 1);
679 // Inject the sealing script then CAPTURE_JS before </body>,
680 // mirroring how the button is injected. The sealing script must
681 // come first so that sealLocation / sealLocationError are defined
682 // before the geolocation handler runs.
683 let seal_js = geo_seal_script(key);
684 let inject = format!("{seal_js}\n{CAPTURE_JS}\n</body>");
685 if with_button.contains("</body>") {
686 with_button.replacen("</body>", &inject, 1)
687 } else {
688 format!("{with_button}\n{seal_js}\n{CAPTURE_JS}")
689 }
690 }
691 }
692}
693
694/// Concise attribute macro alternative to calling [`__location__`] /
695/// [`__location_async__`] directly.
696///
697/// The function **name** becomes the binding; the [`PageTemplate`] is built
698/// from the macro arguments. See the
699/// [crate-level documentation](toolkit_zero::location) and
700/// [`toolkit_zero_macros::browser`] for the full argument reference.
701///
702/// # Example
703///
704/// ```rust,ignore
705/// use toolkit_zero::location::browser::{browser, LocationData, LocationError};
706///
707/// async fn run() -> Result<LocationData, LocationError> {
708/// #[browser(title = "My App", tickbox, consent = "I agree")]
709/// fn loc() {}
710/// Ok(loc)
711/// }
712/// ```
713pub use toolkit_zero_macros::browser;