Files
arah2/web/src/main.ts
T
araharah-executor 5b124e0b75 feat(07-01): wire fullscreen + safe-area watch onto the guidance lifecycle edges
- route.ts: requestGuidanceFullscreen at BOTH guidance-start edges (activateGuidance + resume) directly after engageCloseProtection — synchronous, best-effort, return ignored
- route.ts: exitGuidanceFullscreen at BOTH teardown edges (teardownGuidance + stopGuidanceArtifacts) directly after releaseCloseProtection
- route.ts: watchFullscreenEvents registered exactly ONCE at the guidance-start edge; the callback is a deliberate no-op (observability only, D-14)
- main.ts: watchSafeAreaChanges(getWebApp()) called at the boot site directly after applySafeArea(resolveSafeInsets(getWebApp())) — insets re-resolve at runtime, not only once at boot (T-07-06b)
- no new logging; the route module's state-token console law unchanged
2026-10-25 03:46:35 +08:00

714 lines
32 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, watchSafeAreaChanges } 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, strings, 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,
createHttpRoutingSource,
decodePolyline6,
deriveCumulativeDistanceM,
type RoutingSource,
} 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";
import { GUIDANCE_CAMERA_PADDING, GUIDANCE_CAMERA_PITCH, GUIDANCE_ZOOM, GUIDANCE_EASE_MS } from "./guidance/camera.ts";
import type { BackAction } from "./telegram/backbutton.ts";
import { createGuidanceWatch } from "./guidance/watch.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()));
// PX-04/D-03: the insets re-resolve at RUNTIME too — rotation and fullscreen
// transitions re-apply through the same union authority on the client's
// inset events. Same one-shot getWebApp() value as the apply line above;
// internally guarded (null WebApp / missing onEvent → returns immediately).
watchSafeAreaChanges(getWebApp());
// 3 (declared ahead of their first use inside the closures below) — the
// single-shell state the BackButton resolver and the stale branch read:
// the route phase, the walk total function, the controller itself, and
// the wire's re-apply handle (assigned once the wire exists).
let routePhase: () => RouteState = () => "idle";
let routeController: ReturnType<typeof createRouteController> | null = null;
let backSync: (() => void) | null = null;
// 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 + the BackButton's full PX-01
// graph. The resolver is CONTEXT-AWARE: the controller owns the walk
// decision (guidance → confirm; confirm → CONTINUE; ended → the
// search-root walk; route → the Phase 5 walk; root → close), the wire
// only applies it — per click AND per state change (wire.sync()).
if (wa) {
watchTheme(wa);
const backResolve = (): BackAction => {
const controller = routeController;
if (controller !== null) return controller.backAction();
return { kind: "hidden" }; // pre-boot — nothing pushed yet
};
const wire = wireBackButton(wa, () => backResolve());
backSync = wire.sync;
}
// 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);
// 06-06 — the guidance chrome's mounts: the banner band (the top register
// occupant) and the maneuver list's overlay layer (collapsed default).
const bannerHost = document.createElement("div");
mainEl.appendChild(bannerHost);
const listHost = document.createElement("div");
mainEl.appendChild(listHost);
// 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);
// 13 — the ?g= resume carrier (GUID-05/06): a killed webview re-opens
// straight into its live trip. The id is an opaque UUID validated
// BEFORE any GET (V5 posture); a failed/foreign GET is an honest
// destination-less boot — never a fake route.
const resumeId = (() => {
const raw = new URLSearchParams(window.location.search).get("g");
return raw !== null && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(raw) ? raw : null;
})();
// 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, { withRouteAction: true })
: 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"));
// The document title mirrors the Telegram header (device-gate feedback,
// 2026-10-25): a resolved destination re-titles the app "arah² → Routing to
// <name>" (en/ms via the strings module — the NAME is public data, no
// coordinates, D-12/D-15); the root state restores the bare brand. The
// title element always exists (index.html's <title> is the base).
const titleEl = document.querySelector("title");
if (titleEl !== null) {
titleEl.textContent = destination !== null
? strings[locale].titleRoute.replace("{name}", destination.name)
: "araharah";
}
const recenter = createRecenterControl(mainEl, locale, () => {
if (lastFix !== null && map !== null) {
if (routeController !== null && routeController.guidancePhase === "driving") {
// re-lock at the GUIDANCE pose (UI-SPEC camera table) — the Phase 3
// padding would put the puck back under the strip
follow?.follow(lastFix, {
padding: GUIDANCE_CAMERA_PADDING,
pitch: GUIDANCE_CAMERA_PITCH,
zoom: GUIDANCE_ZOOM,
durationMs: GUIDANCE_EASE_MS,
});
} else {
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,
};
// Phase 6 — the COMPOSED LADDER (D-01's law, code-constructed — no env
// read, no registry, no config): engine-a answers when the Worker can
// (arbitrary origins, real reroutes); the fixture corpus carries the
// golden-demo path and every honest-null fallback. The bearer getter is
// the memory-held session token — minted after this line is still sent.
const fixtureSource = createFixtureRoutingSource();
const httpSource = createHttpRoutingSource({ endpoint: "/api/route", bearer: () => getSession() });
const routingSource: RoutingSource = {
name: "ladder",
route: async (req) => (await httpSource.route(req)) ?? (await fixtureSource.route(req)),
};
routeController = createRouteController({
map: routeMap,
source: routingSource,
locale,
buildRouteCard,
buildPendingCard: buildRoutePendingCard,
showRouteCard,
showDestinationCard: () => showCard(destinationCard),
follow: { follow: (fix, opts) => follow?.follow(fix, opts) }, // 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 (backSync !== null) backSync(); // visible exactly when a pushed state exists
},
// — the guidance concerns (06-06) —
wa,
shellRoot: mainEl, // [data-guidance="active"] compacts the strip through CSS
bannerHost,
listHost,
startWatch: (onFix) => createGuidanceWatch(navigator.geolocation, onFix),
findGantry: (banner) => {
const el = banner.querySelector(".guide-gantry");
return el instanceof HTMLElement ? el : null;
},
onGuidanceChange: () => {
if (backSync !== null) backSync(); // confirm open/close + activation/teardown re-apply the graph
},
// 06-08 G7c: the pump's fixes ride the chain's surfaces — the puck moves
// (the UI-SPEC's recorded call: the puck carries progress) and the
// chain's freshness axis stays live (a drive past 45s never flips
// SIGNAL LOST while fixes flow). The camera is NOT touched here: the
// guidance camera owns the viewport (the same guard handleLive keeps).
updatePuck: (fix) => {
lastFix = fix;
if (map !== null) {
marker.update(map, fix);
marker.setStale(false);
}
},
fetchImpl: fetch.bind(globalThis),
bearer: getSession,
});
// 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();
// 14 — the envelope lifecycle (D-11's structural foreground gate) + the
// ?g= resume. Both ride Telegram's activated/deactivated events
// behind the capability guard (T-06-06); the minimize POST carries
// keepalive so a frozen webview still hands off (RESEARCH Pitfall 2).
if (wa && typeof wa.onEvent === "function" && routeController !== null) {
const guided = routeController;
wa.onEvent("deactivated", () => void guided.onDeactivated());
wa.onEvent("activated", () => guided.onActivated());
}
if (resumeId !== null && routeController !== null && map !== null) {
void tryResume(resumeId, routeController);
}
// Device-gate fix (2026-10-24): the destination card's ROUTE button — the
// forward action SEARCH AGAIN's destination state was missing (auto-consult
// fires exactly once per boot; a user walking back was stranded). Re-consults
// with the latest fix + the resolved destination; no fix yet = the honest
// boot continues (the chain is already running or will render its own state).
const routeButton = destinationCard?.querySelector('[data-role="route-to-destination"]');
if (routeButton instanceof HTMLButtonElement && routeController !== null && destination !== null) {
const controller = routeController;
const dest = destination;
routeButton.addEventListener("click", () => {
const fix = lastFix;
if (fix === null) return; // no position yet — the chain's own surfaces own this state
void controller.consult(fix, dest);
});
}
/** GET the ?g= envelope and resume: rebuild the model from the frozen
* snapshot (decode + cumulative + destination fields), hand the RAW
* anchors to the controller — it owns the projection. A failed,
* foreign, or malformed GET is an honest boot (Phase 4 flow) — never a
* fake route, never guidance chrome over nothing. */
async function tryResume(id: string, controller: NonNullable<typeof routeController>): Promise<void> {
const token = getSession();
if (token === null) return; // no session yet — the honest boot continues
try {
const res = await fetch(`/api/guidance?id=${id}`, { headers: { authorization: `Bearer ${token}` } });
if (!res.ok) return; // absent/foreign → byte-indistinguishable 404 → the honest boot
const payload: unknown = await res.json();
if (payload === null || typeof payload !== "object") return;
const snap = (payload as { route_snapshot?: unknown }).route_snapshot;
if (snap === null || typeof snap !== "object") return;
const s = snap as {
destinationId?: unknown;
destinationName?: unknown;
distanceM?: unknown;
durationS?: unknown;
geometry?: unknown;
startedAtSec?: unknown;
};
if (
typeof s.destinationId !== "string" ||
typeof s.destinationName !== "string" ||
typeof s.geometry !== "string" ||
typeof s.distanceM !== "number" ||
!Number.isFinite(s.distanceM) ||
typeof s.durationS !== "number" ||
!Number.isFinite(s.durationS) ||
typeof s.startedAtSec !== "number" ||
!Number.isSafeInteger(s.startedAtSec) ||
s.startedAtSec <= 0
) {
return; // a corrupt envelope is no envelope (the defensive-read law)
}
const points = decodePolyline6(s.geometry); // throws on truncation → the catch
if (points.length < 2) return;
await mapLayersReady; // the route sources exist before the line draws
controller.resume({
model: {
points,
cumulative: deriveCumulativeDistanceM(points),
steps: [], // the envelope carries geometry only — the controller synthesizes the terminal marks
destinationId: s.destinationId,
destinationName: s.destinationName,
},
envelopeId: id,
distanceM: s.distanceM,
durationS: s.durationS,
startedAtSec: s.startedAtSec,
});
} catch {
return; // fail-honest — never a fabricated trip
}
}
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);
if (routeController !== null && routeController.guidancePhase === "driving") {
// the guidance camera owns the viewport during a live trip — the
// chain's fix refreshes marker/strip; the camera settles on the
// next throttled ease (never a pose fight)
} else {
follow?.follow(fix);
}
}
marker.setStale(false);
recenter.setFixState("fresh");
renderState({ kind: "live", fix });
// Auto-consult (the state-coexistence law): the RoutingSource ladder 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. A ?g= resume boot NEVER consults
// (the envelope's route IS the trip); a failed resume stays honest
// (no destination drawn). The consult waits for the map's load.
if (destination !== null && !routeConsulted && resumeId === null) {
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();
}
}
}
}