app.rs
⎇
Raw
1//! Pages for the Android app: `#device` inside its WebView, `#pair` in the browser.
2
3use api::{PairBegin, PairCode};
4use leptos::prelude::*;
5use leptos::task::spawn_local;
6use wasm_bindgen::prelude::*;
7
8use crate::{http, map};
9
10#[wasm_bindgen(inline_js = r#"
11export function appToken() {
12 return window.OtApp ? window.OtApp.token() : null;
13}
14"#)]
15extern "C" {
16 #[wasm_bindgen(js_name = appToken)]
17 fn app_token() -> Option<String>;
18}
19
20/// The map of this device only. The app hands over its token through a JavaScript bridge.
21#[component]
22pub fn DevicePage() -> impl IntoView {
23 match app_token() {
24 Some(token) => {
25 view! { <main class="device"><map::MapPage source=map::Source::Device(token) /></main> }
26 .into_any()
27 }
28 None => view! { <p class="login">"This page is for the opentracker app."</p> }.into_any(),
29 }
30}
31
32/// A query parameter of `#pair?challenge=…&name=…`.
33fn hash_param(key: &str) -> Option<String> {
34 let hash = window().location().hash().ok()?;
35 let query = hash.split_once('?')?.1;
36 query.split('&').find_map(|pair| {
37 let (k, v) = pair.split_once('=')?;
38 (k == key)
39 .then(|| {
40 js_sys::decode_uri_component(&v.replace('+', " "))
41 .ok()
42 .map(String::from)
43 })
44 .flatten()
45 })
46}
47
48/// Confirms a new device for the signed-in user, then hands a one-time code back to the app.
49#[component]
50pub fn PairPage() -> impl IntoView {
51 let challenge = hash_param("challenge");
52 let name = RwSignal::new(hash_param("name").unwrap_or_else(|| "Android".into()));
53 let error = RwSignal::new(None::<String>);
54 let done = RwSignal::new(None::<String>);
55 let busy = RwSignal::new(false);
56 let has_challenge = challenge.is_some();
57
58 let connect = move |ev: leptos::ev::SubmitEvent| {
59 ev.prevent_default();
60 let Some(challenge) = challenge.clone() else {
61 return;
62 };
63 busy.set(true);
64 let body = PairBegin {
65 challenge,
66 name: name.get_untracked(),
67 };
68 spawn_local(async move {
69 match http::post::<PairCode>("/api/devices/pair/begin", &body).await {
70 Ok(c) => {
71 let url = format!("opentracker://paired?code={}", c.code);
72 let _ = window().location().assign(&url);
73 done.set(Some(url));
74 }
75 Err(e) => error.set(Some(e.to_string())),
76 }
77 busy.set(false);
78 });
79 };
80
81 view! {
82 <form class="login" on:submit=connect>
83 <h1>"Connect the app"</h1>
84 {move || match (has_challenge, done.get()) {
85 (false, _) => view! { <p class="error">"This link is incomplete. Start again in the app."</p> }.into_any(),
86 // Browsers may block opening the app without a tap, so the link stays as a fallback.
87 (true, Some(url)) => view! {
88 <p>"Done. Return to the app to finish."</p>
89 <a class="button primary" href=url>"Open the app"</a>
90 }
91 .into_any(),
92 (true, None) => view! {
93 <p>"The opentracker app asks to upload positions to your account as a new device."</p>
94 <label>"Device name" <input required maxlength="100" bind:value=name /></label>
95 <button class="primary" disabled=busy>"Connect"</button>
96 }
97 .into_any(),
98 }}
99 <p class="error">{move || error.get()}</p>
100 </form>
101 }
102}
103