- 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
714 lines
32 KiB
TypeScript
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();
|
|
}
|
|
}
|
|
}
|
|
}
|