- route-profiles.ts: HARNESS-ONLY module — sentinel ROUTE_PROFILE_HARNESS_ONLY + getRouteProfileOrigin wrapping getPairOrigin; D-12 header (pinned PUBLIC origin only, no coordinate params) + BA5 exclusion note - index.html: ?fixpair=<pair-id> knob resolves the pair's pinned public origin as the mock's telegramFix (accuracy 12, heading null); unknown pairId keeps the default fix; knob documented in the profile comment - [Rule 1 - Bug] main.ts never passed bootMap's opts.onLoad — route sources never existed and setRouteDrawn's setData silently no-op'd (cards rendered, the line never painted; invisible to the RS fakes). Wiring added + RS2 boot-wiring gate pins it - [Rule 1 - Bug] consult raced the map load: harness session stubs resolve the location chain before WebGL/style init, so draw landed on missing sources and the post-load empty install discarded it (probe: ROUTE token fired before RAMS-BASEMAP-READY). Consult now awaits mapLayersReady, resolved in the load handler after installRouteLayers (never wedges — finally-resolved; cards-only boots resolve immediately) - Capture run: CAPTURE OK — 13/13; route line verified painted, alt swap renders ROUTE 2/2 +1 MIN VS ROUTE 1, pending card honest, dark PX-02 - npm test -w web: 76 passed (new RS2 boot-wiring gate included)
526 lines
23 KiB
TypeScript
526 lines
23 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 { telegramLocationSupported } from "./location/telegram.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";
|
|
import { readDestinationId, resolveDestination, setDestination } from "./search/resolve-destination.ts";
|
|
import { buildDestinationCard, buildDestinationUnavailableCard } from "./search/destination-card.ts";
|
|
import { wireHomeScreen } from "./telegram/homescreen.ts";
|
|
import { createFixtureRoutingSource } from "@araharah/routing/src/index.ts";
|
|
import { createRouteController, type RouteState } from "./route/route.ts";
|
|
import { buildRouteCard, buildRoutePendingCard } from "./route/route-card.ts";
|
|
import { attachRouteEndpoints, removeRouteEndpoints } from "./route/route-markers.ts";
|
|
import { installRouteLayers, type MapLike } from "./route/route-layers.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, now carrying the
|
|
// route phase: the BackButton is visible exactly when a PUSHED state
|
|
// exists (route or pending). Both closures are assigned for real once
|
|
// the route controller exists (below) — before that, idle.
|
|
let routePhase: () => RouteState = () => "idle";
|
|
let routeWalkBack = (): void => {};
|
|
|
|
// 12 — the locale comes from the RAW initData string (the session POST
|
|
// returns only {token} — the claim's language_code is not reachable
|
|
// client-side). Derived BEFORE the Telegram-bindings block so the
|
|
// home-screen wire (ONBD-03) receives it; the strip consumes it below
|
|
// exactly where it always has.
|
|
const wa = getWebApp();
|
|
const locale: Locale = wa ? localeFromInitData(wa.initData) : "en";
|
|
|
|
// 2 — Telegram bindings: theme slot switch + BackButton total wire.
|
|
// The predicate + walkBack ship as ONE change (UI-SPEC): the pushed-
|
|
// state click walks to the root via the SAME total function SEARCH
|
|
// AGAIN invokes — never close() from a pushed state (T-05-11).
|
|
if (wa) {
|
|
watchTheme(wa);
|
|
wireBackButton(wa, () => routePhase() !== "idle", () => routeWalkBack());
|
|
}
|
|
|
|
// 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);
|
|
|
|
// The route consult waits on this: the harness session stubs resolve the
|
|
// location chain faster than WebGL/style init, so consult+setData could
|
|
// land BEFORE the map's load event — the draw would hit sources that do
|
|
// not exist yet and the empty post-load install would silently discard it
|
|
// (found by the 05-05 capture legs: card rendered, line never painted).
|
|
// Resolves in map.ts's ONE load handler, right after installRouteLayers;
|
|
// a cards-only boot (map === null) resolves immediately. The Promise
|
|
// executor runs synchronously, so the resolver is assigned before any
|
|
// caller below reaches it.
|
|
let resolveMapLayersReady!: () => void;
|
|
const mapLayersReady: Promise<void> = new Promise((res) => {
|
|
resolveMapLayersReady = res;
|
|
});
|
|
const map = bootMap(document.getElementById("map")!, {
|
|
// The route layers install at map load, inside the ONE load handler
|
|
// (after building-3d, before the ready log — map.ts hosts the hook).
|
|
// 05-05 capture-leg fix: without this callback the sources never exist
|
|
// and setRouteDrawn's setData silently no-ops — cards rendered, the
|
|
// line never painted (the fakes in the RS gates could not see it).
|
|
onLoad: (m) => {
|
|
try {
|
|
installRouteLayers(m as unknown as MapLike);
|
|
} finally {
|
|
resolveMapLayersReady(); // the consult never wedges, even on install failure
|
|
}
|
|
},
|
|
});
|
|
if (!map) {
|
|
document.body.dataset.map = "absent"; // WebGL2 absent — the shell still renders
|
|
resolveMapLayersReady(); // cards-only degradation: nothing to wait for
|
|
}
|
|
|
|
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);
|
|
|
|
// Phase 5 — the route surface's OWN stack (a second register holder): the
|
|
// Phase 3 stack logic above stays byte-identical (the "no destination →
|
|
// Phase 3 behavior byte-identical" law), and the pending + signal-lost
|
|
// coexistence (different registers) falls out of two independent stacks.
|
|
const routeStack = document.createElement("div");
|
|
routeStack.className = "route-stack";
|
|
routeStack.style.display = "none";
|
|
mainEl.appendChild(routeStack);
|
|
|
|
// 12 — the strip consumes the locale derived above the bindings block.
|
|
strip.setLocale(locale);
|
|
|
|
// 13 — the destination (ONBD-02). TWO carriers, ONE validation (mechanism
|
|
// corrected per RESEARCH Pitfall 1): the URL's own query string first
|
|
// (the inline-button path — a web_app button's WebAppInfo carries only
|
|
// a `url`), then initData's start_param (the documented direct-link /
|
|
// home-screen path). An unknown id yields the honest unavailable state
|
|
// and the boot continues into the normal staged boot — never a blank
|
|
// map. The id resolves against the webview's OWN build-time copy of
|
|
// the shared gazetteer, and the result is held in MEMORY ONLY
|
|
// (GEO-03/D-07) — seeded once here, no persistence anywhere.
|
|
const destPayload = readDestinationId(window.location.search, wa?.initData ?? null);
|
|
const destination = resolveDestination(destPayload.id);
|
|
setDestination(destination);
|
|
// The destination surface — the confirmation card when the id resolved, the
|
|
// honest unavailable card when an id ARRIVED and resolved to nothing, and
|
|
// null when no id arrived at all: the Phase 3 boot is then byte-identical
|
|
// (D-12's startapp-less boot landing in the search-ready map is UNCHANGED).
|
|
const destinationCard =
|
|
destination !== null
|
|
? buildDestinationCard(locale, destination)
|
|
: destPayload.id !== null
|
|
? buildDestinationUnavailableCard(locale)
|
|
: null;
|
|
|
|
// ONBD-03 — the home-screen affordance behind the Bot API 8.0 capability
|
|
// guard. Wired after the strip exists (it hosts the affordance) and after
|
|
// the locale is known. Telegram-native methods only — no browser install
|
|
// APIs exist in the webview (T-04-38).
|
|
if (wa) wireHomeScreen(wa, locale);
|
|
|
|
// The destination capture signal — emitted UNCONDITIONALLY (outside any map
|
|
// guard, the same shape as the ARAHARAH-LIVE token): a WebGL2-absent device
|
|
// still resolves the race. A STATE TOKEN, never the id (D-15 hygiene: a
|
|
// console line carries no place data); the capture leg pairs it with a DOM
|
|
// check on the rendered card's text.
|
|
console.log("ARAHARAH-DEST state=" + (destination ? "resolved" : "absent"));
|
|
|
|
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: telegramLocationSupported(wa) },
|
|
);
|
|
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: telegramLocationSupported(wa) },
|
|
);
|
|
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;
|
|
let routeConsulted = false; // the route consults AUTOMATICALLY, EXACTLY ONCE per boot (D-10: no route button)
|
|
|
|
// 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 = "";
|
|
}
|
|
}
|
|
|
|
// The route stack's shower — mirrors showCard's shape. Showing a route or
|
|
// pending card vacates BOTH Phase 3 registers (the map is the center
|
|
// surface per the UI-SPEC state table); walkBack restores the destination
|
|
// card through showCard. Different registers never collide: the pending
|
|
// card lives here (center) while the signal-lost card uses the Phase 3
|
|
// stack's bottom register.
|
|
function showRouteCard(card: HTMLElement | null, register: "center" | "bottom"): void {
|
|
if (card !== null) {
|
|
showCard(null);
|
|
routeStack.replaceChildren(card);
|
|
routeStack.dataset.register = register;
|
|
routeStack.style.display = "";
|
|
} else {
|
|
routeStack.style.display = "none";
|
|
}
|
|
}
|
|
|
|
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":
|
|
// With a preloaded destination the destination surface IS the live
|
|
// state's card (the visible confirmation); with an id that arrived
|
|
// but resolved to nothing, the honest unavailable card holds the
|
|
// register. No id → null → the stack hides exactly as Phase 3 left it.
|
|
// Once the route surface has taken over (route or pending), the
|
|
// destination card must NOT return — a staleness recovery re-enters
|
|
// this branch and must never evict the route/pending card (UI-SPEC
|
|
// state table: "staleness never evicts the route card").
|
|
if (routePhase() === "idle") showCard(destinationCard);
|
|
break;
|
|
case "stale":
|
|
// Stale during route: the route card PERSISTS in the route stack —
|
|
// the strip lamp + dimmed marker carry the signal (UI-SPEC stale-
|
|
// during-route row; evicting the terminal surface would break D-10).
|
|
// Stale during pending: the pending card (route stack, center)
|
|
// COEXISTS with the signal-lost card (Phase 3 stack, bottom) —
|
|
// different registers. Idle: exactly the Phase 3 behavior.
|
|
if (routePhase() !== "route") 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
|
|
}
|
|
|
|
// Phase 5 — the route surface (D-05/D-12: the fixture RoutingSource is
|
|
// consulted in the webview only; the origin stays in memory, no route
|
|
// persists, no /api/route exists). The null-tolerant wrappers keep the
|
|
// controller's types total: on a WebGL2-absent device the shell degrades
|
|
// to cards-only (the no-op MapLike + no-op endpoints; the cards still
|
|
// render — D-09's anti-blank law).
|
|
const routeMap: MapLike =
|
|
map !== null
|
|
? (map as unknown as MapLike) // typed alias at the call site (the map.ts:32 precedent) — MapLibre's Source union is wider than the route seam needs
|
|
: {
|
|
addSource: () => {},
|
|
addLayer: () => {},
|
|
getSource: () => undefined,
|
|
fitBounds: () => {},
|
|
getBearing: () => 0,
|
|
};
|
|
const routingSource = createFixtureRoutingSource();
|
|
const routeController = createRouteController({
|
|
map: routeMap,
|
|
source: routingSource,
|
|
locale,
|
|
buildRouteCard,
|
|
buildPendingCard: buildRoutePendingCard,
|
|
showRouteCard,
|
|
showDestinationCard: () => showCard(destinationCard),
|
|
follow: { follow: (fix) => follow?.follow(fix) }, // null-tolerant wrapper — the controller sees a total object
|
|
getLatestFix: () => lastFix,
|
|
attachEndpoints: (firstPoint, lastPoint, name) => {
|
|
if (map !== null) attachRouteEndpoints(map, firstPoint, lastPoint, name);
|
|
},
|
|
removeEndpoints: () => {
|
|
if (map !== null) removeRouteEndpoints(map);
|
|
},
|
|
onStateChange: (s) => {
|
|
routePhase = () => s; // the ONE source of truth for the overlay predicate AND the stale branch
|
|
if (wa) {
|
|
if (s === "idle") wa.BackButton?.hide();
|
|
else wa.BackButton?.show(); // visible exactly when a pushed state exists
|
|
}
|
|
},
|
|
});
|
|
routeWalkBack = () => routeController.walkBack(); // the SEARCH AGAIN total function, now real
|
|
|
|
// 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 });
|
|
// Auto-consult (the state-coexistence law): the fixture RoutingSource is
|
|
// consulted ONCE per boot, only when the location chain is live AND a
|
|
// destination resolved — destination-unavailable and no-destination
|
|
// boots never make a route request. The consult itself waits for the
|
|
// map's load (route sources installed) — see mapLayersReady above.
|
|
if (destination !== null && !routeConsulted) {
|
|
routeConsulted = true;
|
|
void mapLayersReady.then(() => routeController.consult(fix, destination));
|
|
}
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
}
|