Skip to main content

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">&#128205;</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">&#128205;</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;