347 lines
14 KiB
TypeScript
347 lines
14 KiB
TypeScript
/*
|
|
* main.ts — boot choreography (D-08, D-09, D-10).
|
|
* ORDER IS LOAD-BEARING: the basemap mounts and starts rendering tiles
|
|
* FIRST; the location chain starts SECOND. The reverse order produces the
|
|
* blank map D-09 forbids. Every boot stage renders Rams furniture over the
|
|
* live map — validating then acquiring then live; none blank.
|
|
*
|
|
* Planner amendment (RESEARCH Open Question 1): when WebGL2 is absent,
|
|
* bootMap() returns null and the shell renders over --color-surface-dim
|
|
* with data-map="absent" — no blank screen, no crash. EVERY map-consuming
|
|
* call site is null-guarded; the ARAHARAH-LIVE signal is emitted outside
|
|
* the guard so the capture race still resolves on a map-less device.
|
|
*
|
|
* Theme flips re-slot CSS custom properties only (D-10 / PX-02); watchTheme
|
|
* is the entire theming surface and the map style is never re-set. The Fix
|
|
* lives in this module's closure and is passed by argument (D-07) — no
|
|
* storage API anywhere on the path.
|
|
*/
|
|
|
|
/* Ambient harness constant `__ARAHARAH_HARNESS__` is declared in
|
|
web/src/vite-env.d.ts (its canonical home, plan 03-04) and supplied by
|
|
Vite's `define` (vite.config.ts): true ONLY in --mode harness builds,
|
|
false everywhere else, so plain dev runs cannot hit a ReferenceError. */
|
|
|
|
import "./shell.css";
|
|
import { bootMap } from "./map/map.ts";
|
|
import { createPositionMarker } from "./map/marker.ts";
|
|
import { createFollowController } from "./map/follow.ts";
|
|
import { createStatusStrip } from "./ui/strip.ts";
|
|
import { buildBootCard, buildDeniedCard, buildSignalLostCard, startGantry, stopGantry } from "./ui/cards.ts";
|
|
import { createRecenterControl } from "./ui/recenter.ts";
|
|
import { getWebApp } from "./telegram/webapp.ts";
|
|
import { watchTheme } from "./telegram/theme.ts";
|
|
import { wireBackButton } from "./telegram/backbutton.ts";
|
|
import { resolveSafeInsets, applySafeArea } from "./telegram/safe-area.ts";
|
|
import { createSession } from "./session/client.ts";
|
|
import { getSession, isSessionStale } from "./session/store.ts";
|
|
import { acquireFix, isFixStale, fixAgeSeconds } from "./location/chain.ts";
|
|
import { requestChatFallback } from "./location/chat-pin.ts";
|
|
import type { Fix, LocationState } from "./location/types.ts";
|
|
import { localeFromInitData, type Locale } from "./i18n/strings.ts";
|
|
import { installMockWebApp } from "./mock/telegram-mock.ts";
|
|
|
|
declare global {
|
|
interface Window {
|
|
// set by plan 03-04's profile-configured runner page BEFORE this module
|
|
// runs — read-only here, never written, never a storage surface (D-07)
|
|
__ARAHARAH_MOCK__?: unknown;
|
|
}
|
|
}
|
|
|
|
void boot();
|
|
|
|
async function boot(): Promise<void> {
|
|
// 1 — safe-area vars are set before any layout reads them (PX-03 groundwork).
|
|
applySafeArea(resolveSafeInsets(getWebApp()));
|
|
|
|
// 3 (declared ahead of its first use inside the BackButton closure) — the
|
|
// single-shell predicate for the no-dead-ends wire.
|
|
const overlayPresent = (): boolean => false; // Phase 3: single shell state (UI-SPEC BackButton contract)
|
|
|
|
// 2 — Telegram bindings: theme slot switch + BackButton total wire.
|
|
const wa = getWebApp();
|
|
if (wa) {
|
|
watchTheme(wa);
|
|
wireBackButton(wa, overlayPresent);
|
|
}
|
|
|
|
// 3 — the map register mounts FIRST (D-08/D-09 anti-blank invariant).
|
|
const appRoot = document.getElementById("app") ?? document.body;
|
|
const mainEl = document.createElement("main");
|
|
const mapEl = document.createElement("div");
|
|
mapEl.id = "map";
|
|
mainEl.appendChild(mapEl);
|
|
appRoot.appendChild(mainEl);
|
|
|
|
const map = bootMap(document.getElementById("map")!);
|
|
if (!map) document.body.dataset.map = "absent"; // WebGL2 absent — the shell still renders
|
|
|
|
let follow: ReturnType<typeof createFollowController> | null = null;
|
|
const marker = createPositionMarker(); // created here, attached in the guarded block below
|
|
|
|
// 4 — chrome registers: strip, card stack, recenter control.
|
|
const stripHost = document.createElement("div");
|
|
mainEl.appendChild(stripHost);
|
|
const strip = createStatusStrip(stripHost);
|
|
const stack = document.createElement("div");
|
|
stack.className = "card-stack";
|
|
mainEl.appendChild(stack);
|
|
|
|
// 12 — locale comes from the RAW initData string (the session POST returns
|
|
// only {token} — the claim's language_code is not reachable client-side).
|
|
const locale: Locale = wa ? localeFromInitData(wa.initData) : "en";
|
|
strip.setLocale(locale);
|
|
|
|
const recenter = createRecenterControl(mainEl, locale, () => {
|
|
if (lastFix !== null && map !== null) follow?.follow(lastFix);
|
|
});
|
|
|
|
// Cards are built once at boot (boot-time locale) and reused across re-runs.
|
|
const bootCard1 = buildBootCard(locale, 1);
|
|
const bootCard2 = buildBootCard(locale, 2);
|
|
const deniedCard = buildDeniedCard(
|
|
locale,
|
|
// Debug round 2/3: Telegram-only deep-link via LocationManager.openSettings.
|
|
{ awaitingPin: false, withLocationSettings: wa?.LocationManager !== undefined },
|
|
);
|
|
const awaitingCard = buildDeniedCard(
|
|
locale,
|
|
// Debug round 2: the Telegram-only deep-link. LocationManager presence
|
|
// is the whole eligibility test — off-Telegram (Desktop/browser) there
|
|
// is nothing for openSettings() to open, and navigator.geolocation
|
|
// denial is handled by the OS settings hint copy instead.
|
|
{ awaitingPin: true, withLocationSettings: wa?.LocationManager !== undefined },
|
|
);
|
|
const lostCard = buildSignalLostCard(locale);
|
|
const gantryOf = (card: HTMLElement): HTMLElement => {
|
|
const el = card.querySelector(".gantry");
|
|
return el instanceof HTMLElement ? el : card;
|
|
};
|
|
|
|
let lastFix: Fix | null = null; // in-memory only (D-07)
|
|
let staleHandled = false;
|
|
let tickStarted = false;
|
|
let chainInFlight = false;
|
|
|
|
// D-06: BOTH denied-shaped instances get live CTAs. 'denied-then-waiting'
|
|
// is a second buildDeniedCard instance (03-03 stub note) — wiring only the
|
|
// first left TRY AGAIN / ASK VIA CHAT dead on the awaiting-pin card.
|
|
const wireDeniedActions = (card: HTMLElement): void => {
|
|
const settingsButton = card.querySelector('[data-role="open-location-settings"]');
|
|
if (settingsButton instanceof HTMLButtonElement) {
|
|
// Debug round 2: deep-links Telegram's pane where the user re-grants
|
|
// location for this bot (the "no way to turn on location" report).
|
|
settingsButton.addEventListener("click", () => {
|
|
const lm = wa?.LocationManager;
|
|
if (lm && typeof lm.openSettings === "function") lm.openSettings();
|
|
void runFromSession(); // sheet open OR older client without the API — re-request either way
|
|
});
|
|
}
|
|
const retryButton = card.querySelector(".card-actions .btn-secondary:not([data-role])");
|
|
if (retryButton instanceof HTMLButtonElement) {
|
|
retryButton.addEventListener("click", () => void runFromSession()); // re-runs from link 1
|
|
}
|
|
const askButton = card.querySelector(".btn-primary");
|
|
if (askButton instanceof HTMLButtonElement) {
|
|
askButton.addEventListener("click", () => void askViaChat()); // the Desktop Telegram path
|
|
}
|
|
};
|
|
wireDeniedActions(deniedCard);
|
|
wireDeniedActions(awaitingCard);
|
|
|
|
// Debug round 2: after the user grants location from Telegram's sheet
|
|
// (opened via openSettings), the client fires locationManagerUpdated —
|
|
// re-run the chain immediately instead of waiting for a manual tap.
|
|
// The Wire: only live Data events on the wrapper — guard chainInFlight.
|
|
if (wa && typeof wa.onEvent === "function" && wa.LocationManager) {
|
|
wa.onEvent("locationManagerUpdated", () => {
|
|
if (chainInFlight) return;
|
|
void runFromSession();
|
|
});
|
|
}
|
|
|
|
function showCard(card: HTMLElement | null, register: "center" | "bottom" = "center"): void {
|
|
if (card !== null) {
|
|
stack.replaceChildren(card);
|
|
stack.style.display = "";
|
|
} else {
|
|
stack.style.display = "none";
|
|
}
|
|
if (register === "bottom") {
|
|
// signal-lost folds into the bottom register (UI-SPEC boot table);
|
|
// space-md clearance above the recenter control's safe-bottom offset
|
|
stack.style.top = "auto";
|
|
stack.style.bottom = "calc(var(--safe-bottom, 0px) + var(--spacing-space-xl))";
|
|
stack.style.transform = "none";
|
|
} else {
|
|
stack.style.top = "";
|
|
stack.style.bottom = "";
|
|
stack.style.transform = "";
|
|
}
|
|
}
|
|
|
|
function renderState(state: LocationState): void {
|
|
const ageSec = lastFix !== null ? fixAgeSeconds(lastFix, Date.now()) : null;
|
|
const accuracyM = lastFix !== null ? lastFix.accuracyM : null;
|
|
strip.render(state, ageSec, accuracyM);
|
|
switch (state.kind) {
|
|
case "validating":
|
|
showCard(bootCard1);
|
|
startGantry(gantryOf(bootCard1));
|
|
break;
|
|
case "acquiring":
|
|
showCard(bootCard2);
|
|
startGantry(gantryOf(bootCard2));
|
|
break;
|
|
case "live":
|
|
showCard(null);
|
|
break;
|
|
case "stale":
|
|
showCard(lostCard, "bottom");
|
|
break;
|
|
case "denied":
|
|
showCard(deniedCard);
|
|
break;
|
|
case "awaiting-pin":
|
|
showCard(awaitingCard);
|
|
break;
|
|
default:
|
|
showCard(null);
|
|
}
|
|
}
|
|
|
|
// 8 — every map-consuming call site is behind the null guard: with
|
|
// bootMap() returning null (no WebGL2), map.on/getCanvas/flyTo would
|
|
// throw and convert the graceful fallback into a blank screen (D-09).
|
|
if (map !== null) {
|
|
follow = createFollowController(map, (m) => recenter.setMode(m));
|
|
marker.marker.addTo(map); // FIRST attachment — the map is the marker's only parent
|
|
}
|
|
|
|
// 5 — mock install is a fallback, never an override: the capture runner
|
|
// page installs a profile-configured mock BEFORE this module runs, and
|
|
// an unconditional install would clobber it (Android/light fake over a
|
|
// configured Desktop/dark profile). In plain vite dev nothing set the
|
|
// global, so the default install still fires.
|
|
if ((__ARAHARAH_HARNESS__ || import.meta.env.DEV) && !window.__ARAHARAH_MOCK__) installMockWebApp();
|
|
|
|
// 6 — session is validated SECOND (HMAC server-side, T-03-08); a failure
|
|
// renders the persistent denied card (D-06) and stops — never blank.
|
|
await runFromSession();
|
|
|
|
async function runFromSession(): Promise<void> {
|
|
renderState({ kind: "validating" });
|
|
const session = await createSession(wa?.initData ?? "");
|
|
if (!session.ok) {
|
|
staleHandled = false; // session-first failure is boot-level, not a stale-fix recovery
|
|
renderDenied();
|
|
return;
|
|
}
|
|
staleHandled = false;
|
|
await runChain();
|
|
}
|
|
|
|
function renderDenied(): void {
|
|
renderState({ kind: "denied", reason: "no-permission" });
|
|
}
|
|
|
|
function renderAwaitingPin(): void {
|
|
renderState({ kind: "awaiting-pin", since: Date.now() });
|
|
}
|
|
|
|
// 7 — stage 2 renders BEFORE the chain runs; never an empty card swap.
|
|
async function runChain(): Promise<void> {
|
|
if (chainInFlight) return;
|
|
chainInFlight = true;
|
|
try {
|
|
renderState({ kind: "acquiring", since: Date.now() });
|
|
const { fix, state } = await acquireFix({ wa, getToken: getSession }, lastFix);
|
|
if (fix !== null) {
|
|
handleLive(fix);
|
|
} else if (state.kind === "awaiting-pin") {
|
|
renderAwaitingPin();
|
|
} else {
|
|
renderDenied();
|
|
}
|
|
} finally {
|
|
chainInFlight = false;
|
|
}
|
|
}
|
|
|
|
// 9 — live: guarded map path + the ALWAYS-emitted capture signal.
|
|
function handleLive(fix: Fix): void {
|
|
lastFix = fix;
|
|
staleHandled = false;
|
|
stopGantry(gantryOf(bootCard1));
|
|
stopGantry(gantryOf(bootCard2));
|
|
if (map !== null) {
|
|
marker.update(map, fix);
|
|
follow?.follow(fix);
|
|
}
|
|
marker.setStale(false);
|
|
recenter.setFixState("fresh");
|
|
renderState({ kind: "live", fix });
|
|
if (!tickStarted) startTick();
|
|
// emitted UNCONDITIONALLY (outside the map guard above): a WebGL2-absent
|
|
// device must still resolve the capture race, or its boot reads as failed
|
|
console.log("ARAHARAH-LIVE fix=" + fix.source + " acc=" + String(fix.accuracyM));
|
|
}
|
|
|
|
// 10 — the 1s telemetry tick exists ONLY after live; staleness re-runs the
|
|
// chain (never a continuous poll — Phase 6 owns the cadence, D-05).
|
|
function startTick(): void {
|
|
tickStarted = true;
|
|
setInterval(tick, 1000);
|
|
}
|
|
|
|
function tick(): void {
|
|
if (lastFix === null) return;
|
|
if (!isFixStale(lastFix, Date.now())) {
|
|
renderState({ kind: "live", fix: lastFix }); // refresh ±M / FIX Ns AGO
|
|
return;
|
|
}
|
|
if (staleHandled) return;
|
|
staleHandled = true;
|
|
marker.setStale(true);
|
|
recenter.setFixState("lost");
|
|
renderState({ kind: "stale", fix: lastFix });
|
|
void runChain(); // re-acquire — the re-run refreshes, not the clock
|
|
}
|
|
|
|
// 11 — the chat-fallback trigger (D-06 and GEO-02): Requests the bot's
|
|
// keyboard so a Desktop user can send a pin (issue #77). A thrown
|
|
// request means the session expired (401, D-03): revalidate once,
|
|
// retry once; persistent failure returns to the denied card.
|
|
async function askViaChat(): Promise<void> {
|
|
let token = getSession();
|
|
if (token === null || isSessionStale()) {
|
|
renderState({ kind: "validating" });
|
|
const res = await createSession(wa?.initData ?? "");
|
|
if (!res.ok) {
|
|
renderDenied();
|
|
return;
|
|
}
|
|
token = res.token;
|
|
}
|
|
try {
|
|
renderAwaitingPin();
|
|
await requestChatFallback(token);
|
|
await runChain();
|
|
} catch {
|
|
const res = await createSession(wa?.initData ?? "");
|
|
if (res.ok) {
|
|
try {
|
|
renderAwaitingPin();
|
|
await requestChatFallback(res.token);
|
|
await runChain();
|
|
} catch {
|
|
renderAwaitingPin(); // the fallback request itself failed — AWAITING PIN is honest
|
|
}
|
|
} else {
|
|
renderDenied();
|
|
}
|
|
}
|
|
}
|
|
}
|