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};
25
26
27// ─────────────────────────────────────────────────────────────────────────────
28// Page template
29// ─────────────────────────────────────────────────────────────────────────────
30
31/// Controls what the user sees in the browser when [`__location__`] is called.
32///
33/// # Variants at a glance
34///
35/// | Variant | Description |
36/// |---|---|
37/// | [`PageTemplate::Default`] | Clean single-button page. Title and body text are customisable. |
38/// | [`PageTemplate::Tickbox`] | Same, but requires a checkbox tick before the button activates. Title, body text, and consent label are customisable. |
39/// | [`PageTemplate::Custom`] | Fully custom HTML. Place `{}` where the capture button should appear; the required JavaScript is injected automatically. |
40pub enum PageTemplate {
41    /// A clean, single-button consent page.
42    ///
43    /// Both fields fall back to sensible built-in values when `None`.
44    ///
45    /// # Example
46    ///
47    /// ```no_run
48    /// # use toolkit_zero::location::browser::PageTemplate;
49    /// // fully default
50    /// let t = PageTemplate::default();
51    ///
52    /// // custom title only
53    /// let t = PageTemplate::Default {
54    ///     title:     Some("My App — Location".into()),
55    ///     body_text: None,
56    /// };
57    /// ```
58    Default {
59        /// Browser tab title and `<h1>` heading text.
60        /// Falls back to `"Location Access"` when `None`.
61        title: Option<String>,
62        /// Descriptive paragraph shown below the heading.
63        /// Falls back to a generic description when `None`.
64        body_text: Option<String>,
65    },
66
67    /// Like [`Default`](PageTemplate::Default) but adds a checkbox the user
68    /// must tick before the capture button becomes active.
69    ///
70    /// # Example
71    ///
72    /// ```no_run
73    /// # use toolkit_zero::location::browser::PageTemplate;
74    /// let t = PageTemplate::Tickbox {
75    ///     title:        Some("Verify Location".into()),
76    ///     body_text:    None,
77    ///     consent_text: Some("I agree to share my location with this application.".into()),
78    /// };
79    /// ```
80    Tickbox {
81        /// Browser tab title and `<h1>` heading text.
82        /// Falls back to `"Location Access"` when `None`.
83        title: Option<String>,
84        /// Descriptive paragraph shown below the heading.
85        /// Falls back to a generic description when `None`.
86        body_text: Option<String>,
87        /// Text label shown next to the checkbox.
88        /// Falls back to `"I consent to sharing my location."` when `None`.
89        consent_text: Option<String>,
90    },
91
92    /// A fully custom HTML document.
93    ///
94    /// Place exactly one `{}` in the string where the capture button should
95    /// be injected. The required JavaScript (which POSTs to `/location` and
96    /// `/location-error`) is injected automatically before `</body>`, so you
97    /// do not need to write any geolocation JS yourself.
98    ///
99    /// # Example
100    ///
101    /// ```no_run
102    /// # use toolkit_zero::location::browser::PageTemplate;
103    /// let html = r#"<!DOCTYPE html>
104    /// <html><body>
105    ///   <h1>Where are you?</h1>
106    ///   {}
107    ///   <div id="status"></div>
108    /// </body></html>"#;
109    ///
110    /// let t = PageTemplate::Custom(html.into());
111    /// ```
112    Custom(String),
113}
114
115impl Default for PageTemplate {
116    /// Returns [`PageTemplate::Default`] with both fields set to `None`
117    /// (all text falls back to built-in defaults).
118    fn default() -> Self {
119        PageTemplate::Default {
120            title:     None,
121            body_text: None,
122        }
123    }
124}
125
126// ─────────────────────────────────────────────────────────────────────────────
127// Public data types
128// ─────────────────────────────────────────────────────────────────────────────
129
130/// Geographic coordinates returned by the browser [Geolocation API].
131///
132/// Fields that are optional in the spec are `None` when the browser or device
133/// did not supply them.
134///
135/// [Geolocation API]: https://developer.mozilla.org/en-US/docs/Web/API/GeolocationCoordinates
136#[derive(Debug, Clone, PartialEq)]
137pub struct LocationData {
138    /// Latitude in decimal degrees (WGS 84).
139    pub latitude: f64,
140    /// Longitude in decimal degrees (WGS 84).
141    pub longitude: f64,
142    /// Horizontal accuracy radius in metres (95 % confidence).
143    pub accuracy: f64,
144    /// Altitude in metres above the WGS 84 ellipsoid, if provided by the device.
145    pub altitude: Option<f64>,
146    /// Accuracy of [`altitude`](Self::altitude) in metres, if provided.
147    pub altitude_accuracy: Option<f64>,
148    /// Direction of travel clockwise from true north in degrees `[0, 360)`,
149    /// or `None` when the device is stationary or the sensor is unavailable.
150    pub heading: Option<f64>,
151    /// Ground speed in metres per second, or `None` when unavailable.
152    pub speed: Option<f64>,
153    /// Browser timestamp in milliseconds since the Unix epoch at which the
154    /// position was acquired.
155    pub timestamp_ms: f64,
156}
157
158/// Errors that can be returned by [`__location__`].
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub enum LocationError {
161    /// The user denied the browser's location permission prompt
162    /// (`GeolocationPositionError.PERMISSION_DENIED`, code 1).
163    PermissionDenied,
164    /// The device could not determine its position
165    /// (`GeolocationPositionError.POSITION_UNAVAILABLE`, code 2).
166    PositionUnavailable,
167    /// The browser did not obtain a fix within its internal 30 s timeout
168    /// (`GeolocationPositionError.TIMEOUT`, code 3).
169    Timeout,
170    /// The local HTTP server or the Tokio runtime could not be started.
171    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
206// ─────────────────────────────────────────────────────────────────────────────
207// Shared in-flight state between route handlers
208// ─────────────────────────────────────────────────────────────────────────────
209
210struct GeoState {
211    result:   Option<Result<LocationData, LocationError>>,
212    shutdown: Option<tokio::sync::oneshot::Sender<()>>,
213}
214
215
216/// Capture the user's geographic location by opening the browser to a local
217/// consent page.
218///
219/// This is a **blocking** call. It:
220///
221/// 1. Binds a temporary HTTP server on a free `127.0.0.1` port.
222/// 2. Opens `http://127.0.0.1:<port>/` in the system's default browser.
223/// 3. Waits for the browser to POST location data (or an error) back.
224/// 4. Shuts the server down and returns the result.
225///
226/// Pass a [`PageTemplate`] to control what the user sees in the browser.
227/// Use [`PageTemplate::default()`] for the built-in single-button consent page.
228///
229/// # Errors
230///
231/// | Variant | Cause |
232/// |---|---|
233/// | [`LocationError::PermissionDenied`] | User denied the browser prompt. |
234/// | [`LocationError::PositionUnavailable`] | Device cannot determine position. |
235/// | [`LocationError::Timeout`] | No fix within the browser's 30 s timeout. |
236/// | [`LocationError::ServerError`] | Failed to start the local HTTP server or runtime. |
237///
238/// # Example
239///
240/// ```no_run
241/// use toolkit_zero::location::browser::{__location__, PageTemplate, LocationError};
242///
243/// // Default built-in page
244/// match __location__(PageTemplate::default()) {
245///     Ok(data) => println!("lat={:.6} lon={:.6} ±{:.0}m",
246///                          data.latitude, data.longitude, data.accuracy),
247///     Err(LocationError::PermissionDenied) => eprintln!("access denied"),
248///     Err(e) => eprintln!("error: {e}"),
249/// }
250///
251/// // Tickbox page with custom consent label
252/// let _ = __location__(PageTemplate::Tickbox {
253///     title:        None,
254///     body_text:    None,
255///     consent_text: Some("I agree to share my location.".into()),
256/// });
257/// ```
258///
259/// # Note on async callers
260///
261/// If you are already inside a Tokio async context (e.g. inside
262/// `#[tokio::main]`), use [`__location_async__`] instead — it is the raw
263/// `async fn` and avoids spawning an extra OS thread.
264pub fn __location__(template: PageTemplate) -> Result<LocationData, LocationError> {
265    // If called from within an existing Tokio runtime, calling `block_on` on
266    // that same thread would panic with "Cannot start a runtime from within a
267    // runtime". Detect this case and delegate to a dedicated OS thread that
268    // owns its own single-threaded runtime, then join the result back.
269    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
292/// Async version of [`__location__`].
293///
294/// Directly awaits the capture future — preferred when you are already running
295/// inside a Tokio async context so no extra OS thread needs to be spawned.
296///
297/// # Example
298///
299/// ```no_run
300/// use toolkit_zero::location::browser::{__location_async__, PageTemplate, LocationError};
301///
302/// #[tokio::main]
303/// async fn main() {
304///     match __location_async__(PageTemplate::default()).await {
305///         Ok(data) => println!("lat={:.6} lon={:.6}", data.latitude, data.longitude),
306///         Err(e) => eprintln!("error: {e}"),
307///     }
308/// }
309/// ```
310pub async fn __location_async__(template: PageTemplate) -> Result<LocationData, LocationError> {
311    capture(template).await
312}
313
314// ─────────────────────────────────────────────────────────────────────────────
315// Async core
316// ─────────────────────────────────────────────────────────────────────────────
317
318async fn capture(template: PageTemplate) -> Result<LocationData, LocationError> {
319    // Bind to port 0 so the OS assigns a free port; pass the ready listener
320    // directly to the server to avoid any TOCTOU race.
321    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    // Shared state written by POST handlers, read after the server exits.
338    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    // ── GET / — serve the consent page ────────────────────────────────────
345    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    // ── POST /location — browser geolocation success ───────────────────────
351    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    // ── POST /location-error — browser geolocation failure ────────────────
377    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    // Open the browser before blocking on the server.
404    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
425// ─────────────────────────────────────────────────────────────────────────────
426// HTML rendering
427// ─────────────────────────────────────────────────────────────────────────────
428
429/// The capture button element injected into every template.
430const CAPTURE_BUTTON: &str =
431    r#"<button id="btn" onclick="requestLocation()">Share My Location</button>"#;
432
433/// JavaScript that handles geolocation and POSTs the VEIL-sealed result to
434/// the local server. Injected into every template (including `Custom`).
435/// Requires the VEIL cipher script (from [`veil_cipher_script`]) to be
436/// present on the page so that `window.sealLocation` and
437/// `window.sealLocationError` are available.
438const 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
486/// Shared CSS used by the two built-in templates.
487const 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
517/// Helper for the tickbox template: a tiny inline script that enables /
518/// disables the button based on whether the checkbox is checked.
519const TICKBOX_TOGGLE_JS: &str = r#"<script>
520  function toggleBtn() {
521    document.getElementById('btn').disabled =
522      !document.getElementById('consent').checked;
523  }
524</script>"#;
525
526/// Generates a `<script>` block containing the full VEIL cipher JavaScript
527/// port. The runtime `key` is embedded as an obfuscated char-code array so
528/// it does not appear as a plain string in DevTools source view.
529///
530/// The script exposes two globals used by [`CAPTURE_JS`]:
531/// - `window.sealLocation(data)` — encodes and seals a location payload.
532/// - `window.sealLocationError(data)` — encodes and seals an error payload.
533///
534/// Both functions return a `Uint8Array` ready to POST as
535/// `application/octet-stream`. The sealed bytes are identical to what
536/// [`crate::serialization::seal`] produces with the same key, so the server
537/// route's `.encryption::<T>(SerializationKey::Value(key))` can decrypt them
538/// transparently.
539fn 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">&#128205;</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            // The capture button is disabled by default; toggleBtn() enables it.
695            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">&#128205;</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            // Replace the first {} with the capture button.
729            let with_button = html.replacen("{}", CAPTURE_BUTTON, 1);
730            // Inject the VEIL cipher script then CAPTURE_JS before </body>,
731            // mirroring how the button is injected. The cipher script must
732            // come first so that sealLocation / sealLocationError are defined
733            // before the geolocation handler runs.
734            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}