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