The same frontend bundle serves the web app and a native Android app built with Tauri (a separate repo wraps this build). This doc covers what lives in this repo to support that, plus the Google Play artifacts (Data Safety form, permission set) the Tauri repo's manifest must match. The Tauri repo owns the Android manifest, permissions, CSP allowlist, signing, and the native bridge implementation.
src/lib/platform.ts is the single source of truth:
isNativeBuild()— the build flagVITE_IS_NATIVE === "true"(set by the Tauri build; defaults"false", wired invite.config.tslike the other flags). Deterministic and available at import time.isTauri()— runtime check for Tauri's injected globals (__TAURI_INTERNALS__v2 /__TAURI__v1).isNativeApp()=isNativeBuild() || isTauri()— the predicate everything branches on. The flag is primary because some decisions (service-worker registration inmain.tsx) run before Tauri injects its globals.
To build the native variant: invoke Vite with VITE_IS_NATIVE=true (or the
build-secret parallel HTT_IS_NATIVE=true).
- No service worker.
main.tsxroutes native through the existingcleanupPreviewServiceWorkers()path (the shell serves its own packaged assets; a stray SW would only fight it). A Tauri WebView is a top-level window, so the prior iframe/preview gate didn't catch it. - No in-app purchases. Paid cloud-storage plans are bought and managed on the
web only (Google Play forbids non-Play billing for in-app digital goods). Cloud
sync stays available — a user who subscribed on the web keeps their tier in
the app. Gating:
pricingCta(... native)returns no CTA (src/lib/billing.ts), paid cards/CTAs are hidden inPricingCards.tsx, the plan picker is hidden inRegister.tsx/PlanCheckout.tsx,PendingCheckoutRedirectis disabled, the Stripe portal buttons are hidden inStoragePanel.tsx(the plan still shows, read-only), andcreateCheckout/createPortalthrow as a backstop (billingClient.ts). - External links open in the system browser, not the app WebView, via
openExternal/interceptExternalinplatform.ts. Resolution order under native: the__HTT_NATIVE__bridge if the shell wired it; otherwise Tauri's opener plugin (@tauri-apps/plugin-opener, dynamically imported so it stays off the web bundle); otherwise a new tab. For the opener-plugin path to work the Tauri shell must registertauri-plugin-opener(+ anopener:defaultcapability) — without it (and without the bridge) external links fall back to a new tab, which a WebView opens in-app.
The Tauri repo wires a single global the frontend calls:
window.__HTT_NATIVE__ = {
// Open a URL in the device's default browser, outside the app WebView.
openExternal(url: string): void | Promise<void>;
};If the bridge is absent, openExternal falls back to window.open(..., "_blank").
The TypeScript contract is NativeBridge in src/lib/platform.ts.
Browsers can't open raw TCP sockets, so pulling a session off an AiM MyChron
over Wi-Fi is native-only. The MyChron tile in the logger picker
(LoggerPicker.tsx) starts the real flow only when isNativeApp(); on the web it
keeps its explanatory dialog. The flow lives in MyChronDownload.tsx (lazy) and
drives the Tauri backend through app-defined IPC commands (no capabilities to
configure — allowed by core:default). The client is
src/lib/loggers/mychron/ipc.ts, which reaches Tauri via a dynamic
import("@tauri-apps/api/core") so @tauri-apps/api code-splits into the lazy
MyChron chunk and never enters the web/eager bundle.
IPC contract (args camelCase; all reject with a plain string whose prefix encodes
the category — device unreachable:, device hung:, protocol error:,
unsupported:, Wi-Fi join was declined…, no logger connected …):
| Command | Args | Resolves to |
|---|---|---|
logger_connect |
{ kind:"mychron", host?, wifi? } |
device info |
logger_list_files |
– | file entries |
logger_download_file |
{ name, onProgress: Channel } |
ArrayBuffer (already-inflated XRK) |
logger_disconnect |
– | void |
On Android the flow passes wifi: { ssidPrefix }; the OS shows a system Wi-Fi
picker that only lists networks whose SSID starts with that prefix (the backend
joins + binds the process to the AP via WifiNetworkSpecifier, a case-sensitive
PatternMatcher prefix), and the UI shows a "waiting for you to pick your MyChron…"
state while it's up. The prefix is user-configurable — Settings → MyChron
(AppSettings.mychronSsidPrefix, read in MyChronDownload.tsx), defaulting to
MYCHRON_SSID_PREFIX (ipc.ts); the field is native-gated in SettingsModal.tsx.
On desktop the wifi hint is omitted (the user joins the AP via the OS). The
default prefix value and whether the AP is open or WPA2 are open hardware items
— confirm from a real device. The download returns
decompressed XRK bytes, which go straight into the existing async importer
(parseDatalogFile, wasm worker) named <name>.xrk. The flow owns its
connection and calls logger_disconnect on every exit (close/cancel/error/
unmount). MyChron's LoggerConnection.supportsDeviceDetails is false — no in-app
settings/tracks/firmware tab.
Play requires a publicly reachable account-deletion URL in addition to the in-app
flow. This repo serves /delete-account (src/pages/DeleteAccount.tsx), mounted
un-gated in App.tsx so the URL resolves on every build. It signs the user in
(the deletion edge function derives the account from the session), then reuses the
emailed-code flow in src/plugins/cloud-sync/accountDeletion.ts. List
https://lapwingdata.com/delete-account in the Play Console as the deletion URL.
The in-app path remains Profile → Data & privacy (DataPrivacyPanel.tsx).
Mirror src/pages/Privacy.tsx. Summary of what the hosted service collects when a
user opts into cloud features (the offline app collects nothing off-device):
| Data type | Collected? | Purpose | Notes |
|---|---|---|---|
| Email address | Yes (account) | Account management | Required only to create an account |
| Name (display name) | Yes (account) | Account/app functionality | User-chosen or auto-generated |
| Precise location | Yes, only if the user syncs a session | App functionality | GPS traces inside telemetry logs the user chooses to sync; foreground-only capture |
| App activity / other content | Yes (account) | App functionality (sync) | Garage data, notes, setups, lap snapshots |
| Payment info | No (not collected by us) | — | Stripe handles card data on the web; no purchases in the Android app |
- Encrypted in transit: yes.
- User can request deletion: yes — in-app and at
/delete-account. - Data shared with third parties / advertisers: no. Sub-processors (Supabase, Stripe [web only], optional Google sign-in, Cloudflare Turnstile, the AI provider) process data on our behalf; nothing is sold or used for ads.
| Permission | Why |
|---|---|
INTERNET |
Optional online features: cloud sync, weather, map/satellite tiles, firmware OTA |
ACCESS_FINE_LOCATION + ACCESS_COARSE_LOCATION |
GPS lap timing / phone-as-datalogger and current-location convenience |
BLUETOOTH_SCAN + BLUETOOTH_CONNECT |
Connect to a Dove's Data Logger over BLE (download laps, settings, firmware OTA) |
WAKE_LOCK |
Keep the screen awake during a recording session (src/lib/wakeLock.ts) |
Location is foreground-only — no ACCESS_BACKGROUND_LOCATION, no foreground
service. GPS is captured only while the app is open and actively timing/logging,
which keeps the Play review simple (no background-location declaration). If
background logging is ever added, it requires ACCESS_BACKGROUND_LOCATION, a
persistent foreground-service notification, and extra Play Console justification.
No camera/microphone permission: video export reuses files the user imports; audio is read from the source video, never the mic.