mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Merge origin/main: keep the provider registry, slim it
main grew the Street Level provider registry while this branch removed it (#982–#985: injected providers, an empty composition, the `r` switch field). The registry, params, uiState and pick router come back as main has them and the slimmed layer sits behind the provider contract: - The core (index.js) drives providers as on main, without surface modes, ground casting, FOLLOW, prewarm and nearest-image lookups. A provider's key status is asked with a 10 s timeout and asked again on a switch-on while no key is known. - Mapillary is mapillaryProvider.js over the slim coverage, sequences and viewer modules, which read the filter and the active flag from the provider context. - The viewer host mounts one provider adapter at a time, no prewarm. - The panel has a chip per provider again (the pill switches the layer); no legend, EXPAND stays the Fullscreen API. - The contract drops what no provider implements: groundCaster, meshSampler, getSurface/setSurface, nearestImage, prewarm, reportError, the legend and surface fields of the UI snapshot. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
commit
c29ccd55d6
103 files changed
+5469
-522
No files matched your search
+7
-1
@@ -55,8 +55,14 @@ GOOGLE_MAPS_SERVER_API_KEY=
|
|||||||
# CLIENT-EXPOSED — use a public assets:read token with URL restrictions.
|
# CLIENT-EXPOSED — use a public assets:read token with URL restrictions.
|
||||||
CESIUM_ION_TOKEN=
|
CESIUM_ION_TOKEN=
|
||||||
|
|
||||||
# Optional: OpenAI Realtime voice control. Do not prefix with VITE_.
|
# Optional: OpenAI Realtime voice control. API key is the default cloud auth.
|
||||||
|
# Provider Settings can instead opt voice into the signed-in local
|
||||||
|
# ChatGPT/Codex OAuth session; the browser still receives only a short-lived
|
||||||
|
# Realtime client secret. Do not prefix with VITE_.
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
|
# Optional: forbid API-key voice sessions. Select ChatGPT OAuth in Provider
|
||||||
|
# Settings; missing or expired Codex sign-in then fails closed.
|
||||||
|
# GEV_PREFER_CODEX_OAUTH=true
|
||||||
OPENAI_REALTIME_MODEL=gpt-realtime-2
|
OPENAI_REALTIME_MODEL=gpt-realtime-2
|
||||||
# Cheaper voice tier, used when the GEV MIC panel's MINI toggle is on.
|
# Cheaper voice tier, used when the GEV MIC panel's MINI toggle is on.
|
||||||
# Model ids and prices are external facts that drift — if OpenAI moves this id,
|
# Model ids and prices are external facts that drift — if OpenAI moves this id,
|
||||||
|
|||||||
+58
-9
@@ -1,5 +1,37 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
- Treat explicitly current, complete empty vessel coverage as a successful update,
|
||||||
|
clearing obsolete contacts while retaining stale or incomplete snapshots.
|
||||||
|
|
||||||
|
- Custom Street Level provider switches survive share links and stored state
|
||||||
|
using URL-safe stable provider IDs and a shared boolean-switch codec; existing
|
||||||
|
Mapillary links keep their meaning. Explicit provider toggles reach durable
|
||||||
|
state without adopting unrelated live switch changes.
|
||||||
|
|
||||||
|
- Directions and Recent Imagery accept replaceable acquisition sources, including
|
||||||
|
route transport, imagery catalog, thumbnails, tile templates and attribution.
|
||||||
|
Explicitly removing either source leaves its layer unavailable; standalone
|
||||||
|
defaults preserve the existing providers.
|
||||||
|
|
||||||
|
- Catalog source availability no longer modifies layer objects; unavailable
|
||||||
|
tooltips use layer display names.
|
||||||
|
|
||||||
|
- Street Level with no providers uses the shared unavailable state and refuses
|
||||||
|
activation before initialization, while keeping its layer identity visible.
|
||||||
|
|
||||||
|
- Application source configuration accepts replacements and explicit removal.
|
||||||
|
Missing catalog feeds leave their layers unavailable without blocking startup
|
||||||
|
or making fallback requests; malformed supplied feeds remain configuration errors.
|
||||||
|
|
||||||
|
- Street Level providers are optional in application composition. Omitting a
|
||||||
|
Mapillary source no longer prevents startup; callers can inject a different
|
||||||
|
provider list or an empty list without changing layer identities or share links.
|
||||||
|
|
||||||
|
- Vector tile sources take their allowed tile origin from the configured
|
||||||
|
`tileJsonUrl`, so repointing only that URL at a mirror loads tiles from the
|
||||||
|
mirror or fails visibly instead of silently using OpenFreeMap. Thanks to
|
||||||
|
[daikaginza](https://github.com/daikaginza) (#935).
|
||||||
|
|
||||||
- MCP setup examples use the app's default port, `4173`. Thanks to
|
- MCP setup examples use the app's default port, `4173`. Thanks to
|
||||||
[Huanyi Xie](https://github.com/xiehuanyi) (#934) and
|
[Huanyi Xie](https://github.com/xiehuanyi) (#934) and
|
||||||
[shirunjies8-png](https://github.com/shirunjies8-png) (#959) for the same fix.
|
[shirunjies8-png](https://github.com/shirunjies8-png) (#959) for the same fix.
|
||||||
@@ -63,6 +95,22 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- Keep later satellites when a TLE catalog entry is missing an orbital line.
|
||||||
|
The parser skips the incomplete set and resumes at the next complete named
|
||||||
|
set, instead of losing the rest of the group (#906).
|
||||||
|
|
||||||
|
- Add an experimental ChatGPT/Codex OAuth option for cloud voice in Provider
|
||||||
|
Settings. API-key voice remains the default. Local sign-in can start from
|
||||||
|
the auth button and reports completion, failure, timeout, or expired
|
||||||
|
credentials. OAuth credentials stay on the server; local routes require
|
||||||
|
loopback access and same-site requests. OAuth sessions retain usage data
|
||||||
|
and show unknown cost; caption transcriptions are counted rather than
|
||||||
|
priced into the API-key cap. The auth button keeps keyboard focus after a
|
||||||
|
mode change, and pending sign-in does not discard text in the key fields.
|
||||||
|
Thanks to [Shayan Khan](https://github.com/devv-shayan) for #621 and
|
||||||
|
[TheSmokeDev](https://github.com/TheSmokeDev) for the credential handling
|
||||||
|
from #653.
|
||||||
|
|
||||||
- Stdio servers from one install share a panel key so a page read from one
|
- Stdio servers from one install share a panel key so a page read from one
|
||||||
process can make requests through another. Concurrent malformed-key repairs
|
process can make requests through another. Concurrent malformed-key repairs
|
||||||
now select one winner; unavailable storage or a busy repair retains the
|
now select one winner; unavailable storage or a busy repair retains the
|
||||||
@@ -71,15 +119,16 @@
|
|||||||
proposed diagnosis in #927, and [kvnloo](https://github.com/kvnloo) for identifying
|
proposed diagnosis in #927, and [kvnloo](https://github.com/kvnloo) for identifying
|
||||||
the repair race. Windows/Claude Desktop Cowork confirmation remains outstanding.
|
the repair race. Windows/Claude Desktop Cowork confirmation remains outstanding.
|
||||||
|
|
||||||
- Street Level: Mapillary street-level imagery (free client token, CC BY-SA
|
- Street Level: street-level imagery from providers, Mapillary first (free
|
||||||
4.0). A right-rail panel holds an ON/OFF pill, 360°/flat and captured-since
|
client token, CC BY-SA 4.0). A right-rail panel holds a chip per provider,
|
||||||
filters and an embedded viewer with EXPAND and FIT/FILL. Coverage
|
360°/flat and captured-since filters and an embedded viewer with EXPAND and
|
||||||
lines (below 60 km) and image cones drape on terrain and Google 3D tiles;
|
FIT/FILL. Coverage lines (below 60 km) and image cones drape on terrain and
|
||||||
opening a photo frames it with one camera flight. Share links carry the
|
Google 3D tiles; opening a photo frames it with one camera flight. Share
|
||||||
switch, the filters and the panel's collapsed state. A server proxy adds the
|
links carry the provider switches, the filters and the panel's collapsed
|
||||||
token to coverage tiles, trims and caches them for 24 h, refuses cross-site
|
state. A server proxy adds the token to Mapillary coverage tiles, trims and
|
||||||
requests and redirects, and limits load per IP and upstream. The panel reads
|
caches them for 24 h, refuses cross-site requests and redirects, and limits
|
||||||
KEY REQUIRED without a token and KEY REJECTED when Mapillary refuses it.
|
load per IP and upstream. The panel reads KEY REQUIRED without a token and
|
||||||
|
KEY REJECTED when Mapillary refuses it.
|
||||||
|
|
||||||
- Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera
|
- Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera
|
||||||
list is one keyless GeoJSON request to the agency's OGC API view of its
|
list is one keyless GeoJSON request to the agency's OGC API view of its
|
||||||
|
|||||||
@@ -235,7 +235,13 @@ _Why cockpit mode exists: you're riding a real aircraft over real terrain — an
|
|||||||
|
|
||||||
## 🎙️ Talk to It
|
## 🎙️ Talk to It
|
||||||
|
|
||||||
> Voice needs an **OpenAI key**. Without one the entire app still runs — the mic button just reports voice is unavailable. The same key drives the **AI HUD summary**: a terse, five-word intelligence-style readout of the current view that regenerates as you move.
|
> Voice can use an **OpenAI API key** or, on the same machine, an opt-in signed-in **ChatGPT/Codex OAuth** session. The API-key path remains the default. Selecting ChatGPT OAuth in Provider Settings reuses an existing local sign-in or starts the Codex browser sign-in flow and selects OAuth when it completes. The OpenAI key still drives the **AI HUD summary**: a terse, five-word intelligence-style readout of the current view that regenerates as you move.
|
||||||
|
|
||||||
|
The OAuth option is experimental. OpenAI documents ChatGPT plan usage for the [Responses API](https://developers.openai.com/siwc/token-sharing-open-source/models-and-inference); a supported third-party Realtime contract for reusing Codex tokens has not been confirmed. A successful token mint does not establish billing or support terms.
|
||||||
|
|
||||||
|
GEV reads Codex's file credentials but does not write or refresh them. It checks token shape and expiry, and reports expired sign-in as requiring re-authentication. The login button runs `codex login`, permits one pending attempt, and reports exit failures or a two-minute timeout. `CODEX_HOME` must match the directory that Codex uses. If `CODEX_AUTH_JSON` points elsewhere, sign in separately or fix that mismatch before using the button. Codex credential storage must use `auth.json`; Keychain-only and in-memory storage are not readable by GEV. On Windows, the login button may not be able to start an npm-installed Codex; run `codex login` yourself, then select OAuth. See [Codex credential storage](https://learn.chatgpt.com/docs/auth#credential-storage).
|
||||||
|
|
||||||
|
For a live voice check, run `node scripts/qa-voice-auth.mjs http://127.0.0.1:4189 oauth microphone.wav` against your local dev server. Use a WAV with three seconds of leading silence followed by a short spoken reply request. Replace `oauth` with `api-key` to check the API-key path. The check verifies audio input, a completed spoken reply, usage, resource cleanup, and reconnect. It saves screenshots and credential-free results under `.gev-logs/voice-auth/`.
|
||||||
|
|
||||||
Click **GEV MIC**, grant the microphone, and just talk. This is more than a voice-controlled remote:
|
Click **GEV MIC**, grant the microphone, and just talk. This is more than a voice-controlled remote:
|
||||||
|
|
||||||
@@ -243,7 +249,7 @@ Click **GEV MIC**, grant the microphone, and just talk. This is more than a voic
|
|||||||
- **🎯 Entity Q&A.** Click any plane, ship, or datacenter and ask _"what's this?"_ It answers using the object's live telemetry.
|
- **🎯 Entity Q&A.** Click any plane, ship, or datacenter and ask _"what's this?"_ It answers using the object's live telemetry.
|
||||||
- **👁️ Visual grounding.** At street level, it reads a viewport screenshot to identify legible signage and building names, and is instructed never to hallucinate labels.
|
- **👁️ Visual grounding.** At street level, it reads a viewport screenshot to identify legible signage and building names, and is instructed never to hallucinate labels.
|
||||||
- **🎬 Cinematic framing.** _"Show me the planes overhead"_ pulls the camera back, angles it, and frames the live traffic like a director.
|
- **🎬 Cinematic framing.** _"Show me the planes overhead"_ pulls the camera back, angles it, and frames the live traffic like a director.
|
||||||
- **🔒 Honest and secure.** The agent only confirms actions that succeeded. Your `OPENAI_API_KEY` never touches the browser; the client only gets a short-lived session token.
|
- **🔒 Honest and secure.** The agent only confirms actions that succeeded. Your `OPENAI_API_KEY` never touches the browser; the client only gets a short-lived session token. If you choose **USE CHATGPT OAUTH** in Provider Settings, the local server uses that sign-in only to mint the same kind of short-lived Realtime credential, and refuses the OAuth path for non-loopback clients.
|
||||||
|
|
||||||
Thirty tools, four jobs — the commands below come straight from the product's voice test suite and tool playbook:
|
Thirty tools, four jobs — the commands below come straight from the product's voice test suite and tool playbook:
|
||||||
|
|
||||||
@@ -429,7 +435,7 @@ Seven keys. Five have a free tier, and the two 🔴 ones are metered:
|
|||||||
| --- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| --- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 🟡 | **Cesium ion** | 🗺️ Google Photorealistic 3D, world terrain, and additional ion-hosted imagery stacks. The free Community plan is for eligible individual, personal/non-commercial use and has quotas | [cesium.com/ion](https://cesium.com/ion) — use a public `assets:read` token and check current [pricing/eligibility](https://cesium.com/platform/cesium-ion/pricing/) |
|
| 🟡 | **Cesium ion** | 🗺️ Google Photorealistic 3D, world terrain, and additional ion-hosted imagery stacks. The free Community plan is for eligible individual, personal/non-commercial use and has quotas | [cesium.com/ion](https://cesium.com/ion) — use a public `assets:read` token and check current [pricing/eligibility](https://cesium.com/platform/cesium-ion/pricing/) |
|
||||||
| 🔴 | **Google Maps** | Direct Google Photorealistic 3D + Google place search ([Map Tiles API](https://developers.google.com/maps/documentation/tile)) | [Google Cloud Console](https://console.cloud.google.com/) — URL-restrict it |
|
| 🔴 | **Google Maps** | Direct Google Photorealistic 3D + Google place search ([Map Tiles API](https://developers.google.com/maps/documentation/tile)) | [Google Cloud Console](https://console.cloud.google.com/) — URL-restrict it |
|
||||||
| 🔴 | **OpenAI** | 🎙️ The voice experience + AI HUD summary. The mini model works; the standard model is noticeably smarter. Want Gemini or another provider behind the mic? PRs welcome | [platform.openai.com](https://platform.openai.com) — metered, see costs below |
|
| 🔴 | **OpenAI** | 🎙️ Voice + AI HUD summary through an API key. Provider Settings also offers **USE CHATGPT OAUTH** for cloud voice without removing the API-key option | [platform.openai.com](https://platform.openai.com) — API-key usage is metered, see costs below |
|
||||||
| 🟡 | **AISStream** | 🚢 Live global ships | [aisstream.io](https://aisstream.io) — free signup |
|
| 🟡 | **AISStream** | 🚢 Live global ships | [aisstream.io](https://aisstream.io) — free signup |
|
||||||
| 🟡 | **NASA FIRMS** | 🔥 Live active fires | [firms.modaps.eosdis.nasa.gov](https://firms.modaps.eosdis.nasa.gov/api/map_key/) — free |
|
| 🟡 | **NASA FIRMS** | 🔥 Live active fires | [firms.modaps.eosdis.nasa.gov](https://firms.modaps.eosdis.nasa.gov/api/map_key/) — free |
|
||||||
| 🟡 | **TomTom** | 🚦 Live flow speeds and congestion colors for the simulated traffic layer | [my.tomtom.com](https://my.tomtom.com/keys) — free tier available |
|
| 🟡 | **TomTom** | 🚦 Live flow speeds and congestion colors for the simulated traffic layer | [my.tomtom.com](https://my.tomtom.com/keys) — free tier available |
|
||||||
@@ -508,7 +514,7 @@ Honest numbers, roughly, as of mid-2026 — always check the provider pricing pa
|
|||||||
| **🟢 Most layers** | **$0, no signup.** OpenSky anon, USGS, CelesTrak, adsb.lol, city CCTV, Radio Browser, GBFS, Launch Library 2, transit feeds, OSRM routing, NOAA and ECMWF weather, OpenStreetMap ALPR mapping, bundled datasets. |
|
| **🟢 Most layers** | **$0, no signup.** OpenSky anon, USGS, CelesTrak, adsb.lol, city CCTV, Radio Browser, GBFS, Launch Library 2, transit feeds, OSRM routing, NOAA and ECMWF weather, OpenStreetMap ALPR mapping, bundled datasets. |
|
||||||
| **🟡 The free-key tier** | **$0 with a signup.** AISStream, FIRMS, TomTom, OpenSky, plus Cesium ion for eligible personal/non-commercial use. Provider quotas and eligibility still apply. |
|
| **🟡 The free-key tier** | **$0 with a signup.** AISStream, FIRMS, TomTom, OpenSky, plus Cesium ion for eligible personal/non-commercial use. Provider quotas and eligibility still apply. |
|
||||||
| **🗺️ Google 3D tiles** | **Free through an eligible Cesium ion Community account within its quota; metered through a direct Google key.** Use the direct route for Google place search or commercial deployment, verify current provider terms, and set budget alerts where billing is enabled. |
|
| **🗺️ Google 3D tiles** | **Free through an eligible Cesium ion Community account within its quota; metered through a direct Google key.** Use the direct route for Google place search or commercial deployment, verify current provider terms, and set budget alerts where billing is enabled. |
|
||||||
| **🔴 OpenAI voice** | **The one that costs real money — so the app meters it for you.** Realtime audio runs a few cents per active minute; an evening of heavy use is single-digit dollars. A live session-spend readout sits next to the mic, with an STD/MINI model toggle, a $2 warning, and a **$5 hard cap that ends the session**. The voice context window is kept deliberately short too. |
|
| **🔴 OpenAI voice** | API-key sessions keep the live spend readout, STD/MINI model toggle, $2 warning, and **$5 hard cap**. ChatGPT OAuth is an explicit choice in Provider Settings. Its usage is tracked without an API-dollar estimate or spend cap. The voice context window stays deliberately short. |
|
||||||
|
|
||||||
Google's direct 3D route is surprisingly generous: the first 1,000 Photorealistic
|
Google's direct 3D route is surprisingly generous: the first 1,000 Photorealistic
|
||||||
3D Tiles sessions each month are currently free, and one root request supports
|
3D Tiles sessions each month are currently free, and one root request supports
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ The golden rule: **secret-bearing API keys stay on the server side.** The dev/pr
|
|||||||
| Key | Where it lives | How the browser uses it |
|
| Key | Where it lives | How the browser uses it |
|
||||||
|-----|----------------|--------------------------|
|
|-----|----------------|--------------------------|
|
||||||
| `OPENAI_API_KEY` | Server only | Browser fetches a short-lived **ephemeral** Realtime session token from `/api/realtime/token`; the real key never ships |
|
| `OPENAI_API_KEY` | Server only | Browser fetches a short-lived **ephemeral** Realtime session token from `/api/realtime/token`; the real key never ships |
|
||||||
|
| ChatGPT sign-in through Codex (optional, experimental) | Codex's own `auth.json` (`CODEX_HOME`, default `~/.codex`); GEV reads it server-side and never writes or refreshes it | Only after **USE CHATGPT OAUTH** is selected in Provider Settings: the server mints the same ephemeral Realtime token with the ChatGPT access token, for loopback clients only |
|
||||||
| `AISSTREAM_API_KEY` | Server only | Server holds the AISStream websocket; browser polls the same-origin `/api/vessels` cache |
|
| `AISSTREAM_API_KEY` | Server only | Server holds the AISStream websocket; browser polls the same-origin `/api/vessels` cache |
|
||||||
| OpenSky OAuth (`OPENSKY_CLIENT_ID/SECRET`) | Server only | Server mints + refreshes the token behind `/api/flights` |
|
| OpenSky OAuth (`OPENSKY_CLIENT_ID/SECRET`) | Server only | Server mints + refreshes the token behind `/api/flights` |
|
||||||
| `GOOGLE_MAPS_SERVER_API_KEY` (optional, #33) | Server only | Server calls Places (`/api/google/nearby-places`, `/api/google/text-search`) and the Street View fallback with this key; falls back to `GOOGLE_MAPS_API_KEY` when unset |
|
| `GOOGLE_MAPS_SERVER_API_KEY` (optional, #33) | Server only | Server calls Places (`/api/google/nearby-places`, `/api/google/text-search`) and the Street View fallback with this key; falls back to `GOOGLE_MAPS_API_KEY` when unset |
|
||||||
@@ -74,6 +75,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur
|
|||||||
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
|
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
|
||||||
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control.
|
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control.
|
||||||
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
|
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
|
||||||
|
- **ChatGPT OAuth voice reuses the local Codex sign-in.** `/api/realtime/oauth-status`, `/api/realtime/oauth-login` and `/api/realtime/token?auth=oauth` answer only loopback connections, behind the same cross-site gate as the token endpoint; the login route also refuses a foreign `Origin`. None of them returns the access token. The login route runs `codex login` (or the executable named by `CODEX_BIN`) with an allowlisted environment that leaves out GEV's provider keys, keeps one attempt pending at a time and cancels it after two minutes or when the server stops. **Limits:** any program on this machine that can reach the server can mint Realtime sessions on the signed-in ChatGPT account, as it can with the API key; a local reverse proxy that hides the client's address defeats the loopback check; and OpenAI has not documented this use of Codex credentials for Realtime, so it may stop working or fall outside the account's terms.
|
||||||
|
|
||||||
## Network exposure — the operator threat model
|
## Network exposure — the operator threat model
|
||||||
|
|
||||||
|
|||||||
+70
-12
@@ -1,5 +1,49 @@
|
|||||||
# God's Eye View Current State
|
# God's Eye View Current State
|
||||||
|
|
||||||
|
Vessel sources can report healthy empty coverage using a current, complete
|
||||||
|
snapshot with zero raw rows, a healthy transport and a positive last-message
|
||||||
|
time. This clears obsolete contacts and settles startup without manufacturing
|
||||||
|
position timestamps. Stale, incomplete and malformed empty results retain the
|
||||||
|
existing recovery behavior.
|
||||||
|
|
||||||
|
Catalog source contracts and their consuming layers are declared in
|
||||||
|
`src/app/sourceComposition.js`. Omitted or null acquisition sources preserve layer
|
||||||
|
registration and serialization, report unavailable, and are refused by the existing
|
||||||
|
visibility lifecycle before initialization. Availability is held in an explicit
|
||||||
|
catalog lookup supplied to the lifecycle, without modifying layer instances;
|
||||||
|
unavailable reasons use the layer display name. Invalid supplied methods still fail
|
||||||
|
construction. `createStandaloneApplication` and `createStandaloneCatalog` accept
|
||||||
|
`sources` overrides; unspecified entries retain
|
||||||
|
standalone defaults, and null entries explicitly remove a source. Street Level
|
||||||
|
continues to accept an explicit provider list. An empty Street Level provider
|
||||||
|
list uses the same unavailable lifecycle and disabled layer toggle. Directions (`getRoute`) and
|
||||||
|
Recent Imagery (`searchHls`, `getThumbnail`, `getTileTemplate`) use the same source
|
||||||
|
configuration boundary. Standalone selects their default adapters; layers retain
|
||||||
|
route normalization, imagery selection and rendering. Recent Imagery adapters
|
||||||
|
consume the existing HLS/VIIRS product schema and provide their own tile attribution.
|
||||||
|
The source factories are exported at `layers/directions/source` and
|
||||||
|
`layers/recent-imagery/source`. Configuration is applied at construction time;
|
||||||
|
this does not introduce live source replacement.
|
||||||
|
|
||||||
|
Application catalog composition accepts an optional `streetLevelProviders` list.
|
||||||
|
An explicit list replaces the default Mapillary provider; an empty list removes
|
||||||
|
all imagery providers. Without that option, Mapillary is registered only when
|
||||||
|
`sources.mapillary` is supplied. An absent source leaves Street Level registered
|
||||||
|
with a truthful no-provider state, preserving layer controls and share tokens.
|
||||||
|
Malformed selected sources and provider definitions still fail validation.
|
||||||
|
An explicit provider list bypasses the unused Mapillary source, including its
|
||||||
|
validation. Layer share identities and the legacy Mapillary `m` option remain
|
||||||
|
stable. Custom provider switches use the `r` option (`example-1*panoramax-0`),
|
||||||
|
with URL-safe separators and a shared layer-neutral boolean-switch codec, sorted by provider ID rather
|
||||||
|
than registration order, and round-trip through links and stored state. A receiver
|
||||||
|
applies only switches for its registered providers; absent custom switches leave
|
||||||
|
current defaults unchanged. Explicit provider toggles persist only their requested
|
||||||
|
switch IDs through the state coordinator; unrelated live values remain transient.
|
||||||
|
The custom switch field is bounded to 256 characters;
|
||||||
|
provider registration rejects compositions that cannot fit rather than truncating
|
||||||
|
identities. A malformed or duplicate entry invalidates the complete custom-switch
|
||||||
|
field. Existing whole-link size limits still apply.
|
||||||
|
|
||||||
## God's Eye View in conversations — October 2, 2026
|
## God's Eye View in conversations — October 2, 2026
|
||||||
|
|
||||||
Tool answers that can be shown in God's Eye View include a view: camera, layers,
|
Tool answers that can be shown in God's Eye View include a view: camera, layers,
|
||||||
@@ -396,7 +440,8 @@ the speaker (`mayVoiceClaimSpeaker()`). Progress lines stay outside the reply
|
|||||||
lifecycle and Radio handoff. `turn.span` debug records carry generation and
|
lifecycle and Radio handoff. `turn.span` debug records carry generation and
|
||||||
playback latency, silence, preamble and word-count figures. Caption
|
playback latency, silence, preamble and word-count figures. Caption
|
||||||
transcription (`OPENAI_REALTIME_TRANSCRIBE_MODEL`, default on) is metered into
|
transcription (`OPENAI_REALTIME_TRANSCRIBE_MODEL`, default on) is metered into
|
||||||
the session cost and cap. The debug log omits transcripts, text, tool arguments
|
the session cost and cap in API-key sessions; ChatGPT OAuth sessions count
|
||||||
|
captions without pricing them. The debug log omits transcripts, text, tool arguments
|
||||||
and complete tool-result bodies (including serialized function outputs) unless
|
and complete tool-result bodies (including serialized function outputs) unless
|
||||||
`GEV_VOICE_LOG_CONTENT=1`.
|
`GEV_VOICE_LOG_CONTENT=1`.
|
||||||
Tool names, call IDs, protocol event metadata and usage remain available.
|
Tool names, call IDs, protocol event metadata and usage remain available.
|
||||||
@@ -1139,6 +1184,10 @@ The Node-only `gods-eye-view/server/providers/space` export supplies factories;
|
|||||||
`sources/space` supplies fixed upstream URL builders with no I/O or environment
|
`sources/space` supplies fixed upstream URL builders with no I/O or environment
|
||||||
access. Callers retain validation, transport and response policy.
|
access. Callers retain validation, transport and response policy.
|
||||||
|
|
||||||
|
The shared TLE text parser skips incomplete named sets and resumes at the next
|
||||||
|
complete set. A missing orbital line does not discard the later satellites in
|
||||||
|
the catalog; satellite layers and next-pass tools use the same parser.
|
||||||
|
|
||||||
## Build configuration and local provider boundaries
|
## Build configuration and local provider boundaries
|
||||||
|
|
||||||
`vite.config.js` delegates to `server/standalone/vite.config.js`, which loads
|
`vite.config.js` delegates to `server/standalone/vite.config.js`, which loads
|
||||||
@@ -3028,10 +3077,14 @@ Street Level is a docked, collapsible right-rail panel (`#street-level-panel`,
|
|||||||
layer token `0`, option owner `street-level`, panel `ui` token `t`) that starts
|
layer token `0`, option owner `street-level`, panel `ui` token `t`) that starts
|
||||||
collapsed. It opens itself only when the user, voice or a tool switches the
|
collapsed. It opens itself only when the user, voice or a tool switches the
|
||||||
layer on or a photo opens, never on a restore, and those opens are not stored.
|
layer on or a photo opens, never on a restore, and those opens are not stored.
|
||||||
It has 360°/flat and captured-since filters (relative days, so a link keeps
|
It has a chip per imagery provider (the contract is in
|
||||||
its meaning), one embedded viewer and an on-globe credit while the layer is on. Share options:
|
`src/layers/streetLevel/registry.js`; Mapillary, `mapillaryProvider.js`, is the
|
||||||
`m` (Mapillary on/off; off draws nothing), `p` (`a`/`p`/`f` panoramas), `s`
|
default and so far the only one), 360°/flat and captured-since filters
|
||||||
(since, days). The header pill is the only layer switch.
|
(relative days, so a link keeps its meaning), one embedded viewer and an
|
||||||
|
on-globe credit per active provider. Share options: `m` (Mapillary on/off), `r`
|
||||||
|
(custom provider switches by id), `p` (`a`/`p`/`f` panoramas), `s` (since,
|
||||||
|
days). The header pill switches the layer; a chip switches its provider, and
|
||||||
|
darkening the last lit chip switches the layer off.
|
||||||
|
|
||||||
Coverage is z11–14 sequence lines below 60 km; above that nothing is drawn and
|
Coverage is z11–14 sequence lines below 60 km; above that nothing is drawn and
|
||||||
the panel says to zoom in. Tiles come from the visible ground within range of
|
the panel says to zoom in. Tiles come from the visible ground within range of
|
||||||
@@ -3056,10 +3109,8 @@ if newer navigation took the camera while the photo loaded; closing the photo
|
|||||||
cancels it. EXPAND is the browser's Fullscreen API on the viewer element; on
|
cancels it. EXPAND is the browser's Fullscreen API on the viewer element; on
|
||||||
iPhone, which has no element fullscreen, the viewer opens as a top-layer
|
iPhone, which has no element fullscreen, the viewer opens as a top-layer
|
||||||
popover instead (hidden where neither API exists). Clean View, recording and
|
popover instead (hidden where neither API exists). Clean View, recording and
|
||||||
the cockpit take an expanded photo out of full screen. With the layer on but
|
the cockpit take an expanded photo out of full screen. At phone width the
|
||||||
Mapillary switched off by a link or tool, the header pill reads OFF and turns
|
viewer height comes from the rail band so the whole photo fits.
|
||||||
Mapillary back on. At phone width the viewer height comes from the rail band
|
|
||||||
so the whole photo fits.
|
|
||||||
|
|
||||||
Browser gates: `npm run qa:street-level -- --url <server>` runs against real
|
Browser gates: `npm run qa:street-level -- --url <server>` runs against real
|
||||||
Mapillary; `npm run qa:street-level:fixtures` answers every Mapillary request
|
Mapillary; `npm run qa:street-level:fixtures` answers every Mapillary request
|
||||||
@@ -4011,7 +4062,7 @@ and unreachable upstream (502/504) separately from road geometry.
|
|||||||
|
|
||||||
`GEV MIC` button (bottom UI) starts an OpenAI Realtime session over WebRTC:
|
`GEV MIC` button (bottom UI) starts an OpenAI Realtime session over WebRTC:
|
||||||
|
|
||||||
- **Token flow**: browser fetches a short-lived client secret from `/api/realtime/token`; the Vite middleware holds `OPENAI_API_KEY` and posts the full session config (instructions, tool schemas, VAD, truncation) to `api.openai.com/v1/realtime/client_secrets`. SDP exchange goes directly to `api.openai.com/v1/realtime/calls` with the ephemeral token.
|
- **Token flow**: browser fetches a short-lived client secret from `/api/realtime/token`; the Vite middleware uses `OPENAI_API_KEY` by default, or the signed-in local ChatGPT/Codex OAuth access token when OAuth was explicitly selected in Provider Settings, and posts the full session config (instructions, tool schemas, VAD, truncation) to `api.openai.com/v1/realtime/client_secrets`. Both long-lived credentials stay server-side; OAuth minting is loopback-only. SDP exchange goes directly to `api.openai.com/v1/realtime/calls` with the ephemeral token.
|
||||||
- **Session defaults** (env-tunable): model `gpt-realtime-2` (or `gpt-realtime-2.1-mini` when the MINI tier is selected — see the model-tier entry below), voice `marin`, reasoning effort `low`, semantic VAD with low eagerness, no response interruption, context window truncated to ~3,000 post-instruction tokens with 0.5 retention ratio — the conversational window stays short because map state is fetched live per turn.
|
- **Session defaults** (env-tunable): model `gpt-realtime-2` (or `gpt-realtime-2.1-mini` when the MINI tier is selected — see the model-tier entry below), voice `marin`, reasoning effort `low`, semantic VAD with low eagerness, no response interruption, context window truncated to ~3,000 post-instruction tokens with 0.5 retention ratio — the conversational window stays short because map state is fetched live per turn.
|
||||||
- **Thirty tools** (argument schemas in `src/voice/actionSchemas.js`, served with their descriptions by `server/providers/openai/tools.js`, executed client-side in `src/voice/gevActions.js`): `fly_to_location`, `select_nearest_aircraft`, `adjust_camera_zoom`, `zoom_to_globe`, `set_layer_visibility`, `show_data_layers_menu`, `set_panel_open`, `set_visual_style`, `get_entity_context`, `get_current_view_state`, `set_hud`, `set_cyber_sonar`, `set_detection`, `set_map_stack`, `set_post_processing`, `control_scene`, `control_cctv`, `set_context_mode`, `control_cockpit`, `control_radio`, `track_entity`, `stop_tracking`, `frame_overhead`, `annotate_map`, `clear_annotations`, `move_camera`, `fly_route`, `analyst_query`, `next_iss_pass`, and `next_satellite_pass`.
|
- **Thirty tools** (argument schemas in `src/voice/actionSchemas.js`, served with their descriptions by `server/providers/openai/tools.js`, executed client-side in `src/voice/gevActions.js`): `fly_to_location`, `select_nearest_aircraft`, `adjust_camera_zoom`, `zoom_to_globe`, `set_layer_visibility`, `show_data_layers_menu`, `set_panel_open`, `set_visual_style`, `get_entity_context`, `get_current_view_state`, `set_hud`, `set_cyber_sonar`, `set_detection`, `set_map_stack`, `set_post_processing`, `control_scene`, `control_cctv`, `set_context_mode`, `control_cockpit`, `control_radio`, `track_entity`, `stop_tracking`, `frame_overhead`, `annotate_map`, `clear_annotations`, `move_camera`, `fly_route`, `analyst_query`, `next_iss_pass`, and `next_satellite_pass`.
|
||||||
|
|
||||||
@@ -4057,7 +4108,10 @@ and unreachable upstream (502/504) separately from road geometry.
|
|||||||
2. **Contacts OFF** → "nearby" means **in view**; "near \<place\>" means a radius around that place. A radius query with Contacts active and no explicit centre is centred on the **active contact**, not the camera.
|
2. **Contacts OFF** → "nearby" means **in view**; "near \<place\>" means a radius around that place. A radius query with Contacts active and no explicit centre is centred on the **active contact**, not the camera.
|
||||||
3. **Every count names its scope in words** — "42 in your window", "8 in view", "about 30 within 250 km of Austin" — never a bare number. `analyst_query` returns `scopeLabel` so this is mechanical. Two different numbers with named scopes are not a contradiction.
|
3. **Every count names its scope in words** — "42 in your window", "8 in view", "about 30 within 250 km of Austin" — never a bare number. `analyst_query` returns `scopeLabel` so this is mechanical. Two different numbers with named scopes are not a contradiction.
|
||||||
4. **The loaded-data caveat is stated once when relevant**: counts cover loaded data, and the flights layer loads where you look (appended to `coverage.note` for radius/view scopes over viewport-loaded layers).
|
4. **The loaded-data caveat is stated once when relevant**: counts cover loaded data, and the flights layer loads where you look (appended to `coverage.note` for radius/view scopes over viewport-loaded layers).
|
||||||
- **Degradation**: without `OPENAI_API_KEY`, `/api/realtime/token` returns 503 and the mic button surfaces the error; the rest of the app is unaffected.
|
- **Degradation**: API-key mode without `OPENAI_API_KEY` returns 503 as before. Selecting OAuth without a usable local ChatGPT/Codex sign-in starts the local `codex login` browser flow, polls for the resulting local token, and selects OAuth when sign-in completes. If the Codex login flow cannot start or does not complete, the rest of the app is unaffected.
|
||||||
|
- **Login lifecycle and storage**: one pending Codex login is shared by the login and status routes. Process failure, success without readable credentials, and a two-minute timeout each report a clear failure; timeout and server shutdown cancel the owned process. The launcher resolves `CODEX_HOME` before changing directories and refuses a mismatched `CODEX_AUTH_JSON`. GEV reads file credentials only, never writes or refreshes them, and does not require the vendor refresh token. Expired access tokens report `CODEX_OAUTH_REAUTH_REQUIRED`. OAuth status and login routes also use the same-site request gate.
|
||||||
|
- **Auth button focus**: changing voice auth updates the existing controls in place. Enter, Space, and mouse selection keep focus on the auth button; Tab then follows the normal control order. Pending sign-in blocks repeated activation without removing the button from the focus order. Completion does not take focus from another control or discard text in the key fields. `scripts/qa-voice-auth-focus.mjs` checks these paths in Chromium with fixture sign-in responses.
|
||||||
|
- **Support boundary**: the local OAuth option is experimental. OpenAI's documented Sign in with ChatGPT plan-sharing inference endpoint is `/v1/responses`; support for third-party reuse of Codex credentials at `/v1/realtime/client_secrets` remains unconfirmed. Technical success does not establish a supported auth or billing contract.
|
||||||
|
|
||||||
### AI HUD Summary (June 2026)
|
### AI HUD Summary (June 2026)
|
||||||
|
|
||||||
@@ -4277,7 +4331,7 @@ are omitted rather than framing the wrong part of the globe.
|
|||||||
- GBFS proxy refuses upstream redirects (`redirect: 'manual'`; any 3xx becomes a 502 and the redirect target is logged server-side only) and enforces its 5 MB response cap while the body streams, cancelling the upstream read past the cap; CCTV health map is bounded.
|
- GBFS proxy refuses upstream redirects (`redirect: 'manual'`; any 3xx becomes a 502 and the redirect target is logged server-side only) and enforces its 5 MB response cap while the body streams, cancelling the upstream read past the cap; CCTV health map is bounded.
|
||||||
- Proxy error payloads are sanitized (no internal error details returned to clients).
|
- Proxy error payloads are sanitized (no internal error details returned to clients).
|
||||||
- That holds for the OpenAI and CCTV media paths too: `/api/openai/hud-summary` never relays OpenAI's own `error.message`, `/api/realtime/token` passes successful ephemeral-token responses through but answers with a fixed error when minting fails or upstream rejects the request, and a failed CCTV media fetch stores a fixed camera health `message` — `GET /api/cctv/health` serializes that field and the CCTV panel renders it as a status label, so it is a client surface as much as the response body is.
|
- That holds for the OpenAI and CCTV media paths too: `/api/openai/hud-summary` never relays OpenAI's own `error.message`, `/api/realtime/token` passes successful ephemeral-token responses through but answers with a fixed error when minting fails or upstream rejects the request, and a failed CCTV media fetch stores a fixed camera health `message` — `GET /api/cctv/health` serializes that field and the CCTV panel renders it as a status label, so it is a client surface as much as the response body is.
|
||||||
- `OPENAI_API_KEY` is server-side only; the browser receives ephemeral Realtime client secrets from `/api/realtime/token`.
|
- `OPENAI_API_KEY` remains server-side only and remains the default voice credential. The optional OAuth mode reads the local ChatGPT/Codex access token server-side on loopback only; the browser receives only ephemeral Realtime client secrets from `/api/realtime/token`.
|
||||||
- `AISSTREAM_API_KEY` is server-side only; the browser reads the same-origin `/api/vessels` cache.
|
- `AISSTREAM_API_KEY` is server-side only; the browser reads the same-origin `/api/vessels` cache.
|
||||||
- `/api/google/nearby-places` keeps the Google key out of Places requests issued for voice scene context.
|
- `/api/google/nearby-places` keeps the Google key out of Places requests issued for voice scene context.
|
||||||
- `/api/google/text-search` keeps the Google key server-side for view-biased Places recovery used by annotation resolution.
|
- `/api/google/text-search` keeps the Google key server-side for view-biased Places recovery used by annotation resolution.
|
||||||
@@ -4680,6 +4734,10 @@ easier to meet (detection is now on more often), but does not create it.
|
|||||||
shown dot against the allowed source classes and records phase timings.
|
shown dot against the allowed source classes and records phase timings.
|
||||||
Positions and segment distances are precomputed, with no new animation-loop
|
Positions and segment distances are precomputed, with no new animation-loop
|
||||||
allocation. Visible-globe roads use cached terrain without offscreen mesh picks. The road-source label names the geometry being drawn, with partial/unavailable states shown plainly.
|
allocation. Visible-globe roads use cached terrain without offscreen mesh picks. The road-source label names the geometry being drawn, with partial/unavailable states shown plainly.
|
||||||
|
- Vector tile sources accept tiles only from an allowed origin, which defaults
|
||||||
|
to the configured `tileJsonUrl` origin; an explicit `allowedOrigin` overrides
|
||||||
|
it. Repointing only `tileJsonUrl` loads tiles from that host or fails with an
|
||||||
|
origin error rather than silently falling back to OpenFreeMap.
|
||||||
- TileJSON caches successful metadata. Transient failures retry after a
|
- TileJSON caches successful metadata. Transient failures retry after a
|
||||||
five-second cooldown; invalid metadata/origins stay unavailable until the
|
five-second cooldown; invalid metadata/origins stay unavailable until the
|
||||||
source is cleared. Clear resets metadata and cancels pending requests.
|
source is cleared. Clear resets metadata and cancels pending requests.
|
||||||
|
|||||||
@@ -162,6 +162,7 @@
|
|||||||
"./layers/vessels": "./src/layers/vessels/index.js",
|
"./layers/vessels": "./src/layers/vessels/index.js",
|
||||||
"./layers/earthquakes": "./src/layers/earthquakes/index.js",
|
"./layers/earthquakes": "./src/layers/earthquakes/index.js",
|
||||||
"./layers/recent-imagery": "./src/layers/recentImagery/index.js",
|
"./layers/recent-imagery": "./src/layers/recentImagery/index.js",
|
||||||
|
"./layers/recent-imagery/source": "./src/layers/recentImagery/source.js",
|
||||||
"./layers/perimeters": "./src/layers/perimeters/index.js",
|
"./layers/perimeters": "./src/layers/perimeters/index.js",
|
||||||
"./sources/firms-csv": "./src/data/firmsCsv.js",
|
"./sources/firms-csv": "./src/data/firmsCsv.js",
|
||||||
"./layers/firms": "./src/layers/firms/index.js",
|
"./layers/firms": "./src/layers/firms/index.js",
|
||||||
@@ -172,6 +173,7 @@
|
|||||||
"./layers/traffic": "./src/layers/traffic/index.js",
|
"./layers/traffic": "./src/layers/traffic/index.js",
|
||||||
"./layers/bikeshare": "./src/layers/bikeshare/index.js",
|
"./layers/bikeshare": "./src/layers/bikeshare/index.js",
|
||||||
"./layers/directions": "./src/layers/directions/index.js",
|
"./layers/directions": "./src/layers/directions/index.js",
|
||||||
|
"./layers/directions/source": "./src/layers/directions/source.js",
|
||||||
"./layers/cctv": "./src/layers/cctv/index.js",
|
"./layers/cctv": "./src/layers/cctv/index.js",
|
||||||
"./layers/alpr": "./src/layers/alpr/index.js",
|
"./layers/alpr": "./src/layers/alpr/index.js",
|
||||||
"./layers/radio": "./src/layers/radio/index.js",
|
"./layers/radio": "./src/layers/radio/index.js",
|
||||||
@@ -260,6 +262,7 @@
|
|||||||
"node": "./server/providers/mapillary.js"
|
"node": "./server/providers/mapillary.js"
|
||||||
},
|
},
|
||||||
"./layers/street-level": "./src/layers/streetLevel/index.js",
|
"./layers/street-level": "./src/layers/streetLevel/index.js",
|
||||||
|
"./layers/street-level/mapillary": "./src/layers/streetLevel/mapillaryProvider.js",
|
||||||
"./layers/street-level/mapillary/source": "./src/layers/streetLevel/source.js",
|
"./layers/street-level/mapillary/source": "./src/layers/streetLevel/source.js",
|
||||||
"./tools": "./src/tools/index.js",
|
"./tools": "./src/tools/index.js",
|
||||||
"./tools/mcp": "./src/tools/mcp/index.js",
|
"./tools/mcp": "./src/tools/mcp/index.js",
|
||||||
|
|||||||
@@ -347,7 +347,7 @@ case "${OPENSKY_AUTH_MODE}" in
|
|||||||
echo "OpenSky auth: disabled (anonymous mode)"
|
echo "OpenSky auth: disabled (anonymous mode)"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
[[ -n "${OPENAI_API_KEY}" ]] && echo "OpenAI key (voice + HUD summary): configured" || echo "OpenAI key (voice + HUD summary): not set — GEV MIC disabled"
|
[[ -n "${OPENAI_API_KEY}" ]] && echo "OpenAI key (voice + HUD summary): configured" || echo "OpenAI key (voice + HUD summary): not set — GEV MIC off unless a Codex ChatGPT sign-in is selected in Provider Settings (experimental)"
|
||||||
[[ -n "${AISSTREAM_API_KEY}" ]] && echo "AISStream key (live vessels): configured" || echo "AISStream key (live vessels): not set — ships layer empty"
|
[[ -n "${AISSTREAM_API_KEY}" ]] && echo "AISStream key (live vessels): configured" || echo "AISStream key (live vessels): not set — ships layer empty"
|
||||||
if [[ -n "${GOOGLE_MAPS_API_KEY}" ]]; then
|
if [[ -n "${GOOGLE_MAPS_API_KEY}" ]]; then
|
||||||
echo "Startup map: Google Photorealistic 3D Tiles (direct)"
|
echo "Startup map: Google Photorealistic 3D Tiles (direct)"
|
||||||
|
|||||||
@@ -36,6 +36,10 @@
|
|||||||
"src/main.js",
|
"src/main.js",
|
||||||
"docs/APPLICATION.md",
|
"docs/APPLICATION.md",
|
||||||
"scripts/qa-application.mjs",
|
"scripts/qa-application.mjs",
|
||||||
|
"scripts/qa-voice-auth.mjs",
|
||||||
|
"scripts/qa-voice-auth-focus.mjs",
|
||||||
|
"src/codexOauthRealtime.test.mjs",
|
||||||
|
"src/voice/cloudVoiceAuth.test.mjs",
|
||||||
"scripts/qa-terrain-429.mjs",
|
"scripts/qa-terrain-429.mjs",
|
||||||
"src/app/application.js",
|
"src/app/application.js",
|
||||||
"src/app/application.test.mjs",
|
"src/app/application.test.mjs",
|
||||||
@@ -862,6 +866,20 @@
|
|||||||
"src/ui/streetLevelPresentation.test.mjs",
|
"src/ui/streetLevelPresentation.test.mjs",
|
||||||
"src/app/layers/streetLevel.js",
|
"src/app/layers/streetLevel.js",
|
||||||
"src/tooling/mapillaryProvider.test.mjs",
|
"src/tooling/mapillaryProvider.test.mjs",
|
||||||
|
"src/app/layers/streetLevel.test.mjs",
|
||||||
|
"src/data/idSwitches.js",
|
||||||
|
"src/layers/streetLevel/credits.js",
|
||||||
|
"src/layers/streetLevel/mapillaryProvider.js",
|
||||||
|
"src/layers/streetLevel/mapillaryProvider.test.mjs",
|
||||||
|
"src/layers/streetLevel/params.js",
|
||||||
|
"src/layers/streetLevel/params.test.mjs",
|
||||||
|
"src/layers/streetLevel/pickRouter.js",
|
||||||
|
"src/layers/streetLevel/pickRouter.test.mjs",
|
||||||
|
"src/layers/streetLevel/providerSwitches.test.mjs",
|
||||||
|
"src/layers/streetLevel/registry.js",
|
||||||
|
"src/layers/streetLevel/registry.test.mjs",
|
||||||
|
"src/layers/streetLevel/uiState.js",
|
||||||
|
"src/layers/streetLevel/uiState.test.mjs",
|
||||||
"src/layers/streetLevel/source.test.mjs",
|
"src/layers/streetLevel/source.test.mjs",
|
||||||
"src/streetLevelMarkup.test.mjs",
|
"src/streetLevelMarkup.test.mjs",
|
||||||
"src/layers/streetLevel/index.js",
|
"src/layers/streetLevel/index.js",
|
||||||
|
|||||||
@@ -293,6 +293,7 @@
|
|||||||
"server/providers/common/request.js",
|
"server/providers/common/request.js",
|
||||||
"server/providers/common/source-root.js",
|
"server/providers/common/source-root.js",
|
||||||
"server/providers/openai.js",
|
"server/providers/openai.js",
|
||||||
|
"server/providers/openai/codex-auth.js",
|
||||||
"server/providers/openai/constants.js",
|
"server/providers/openai/constants.js",
|
||||||
"server/providers/openai/debug-log.js",
|
"server/providers/openai/debug-log.js",
|
||||||
"server/providers/openai/hud-summary.js",
|
"server/providers/openai/hud-summary.js",
|
||||||
@@ -549,6 +550,7 @@
|
|||||||
"src/contextModePolicy.js",
|
"src/contextModePolicy.js",
|
||||||
"src/data/detectionPolicy.js",
|
"src/data/detectionPolicy.js",
|
||||||
"src/data/installationFeedback.js",
|
"src/data/installationFeedback.js",
|
||||||
|
"src/data/idSwitches.js",
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateCoordinator.js",
|
"src/data/layerStateCoordinator.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
@@ -797,6 +799,7 @@
|
|||||||
"src/data/cockpitContactDot.js",
|
"src/data/cockpitContactDot.js",
|
||||||
"src/data/contactMatch.js",
|
"src/data/contactMatch.js",
|
||||||
"src/data/iconOrientation.js",
|
"src/data/iconOrientation.js",
|
||||||
|
"src/data/idSwitches.js",
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
"src/data/modelVisualAnchor.js",
|
"src/data/modelVisualAnchor.js",
|
||||||
@@ -839,6 +842,7 @@
|
|||||||
"src/data/cockpitContactDot.js",
|
"src/data/cockpitContactDot.js",
|
||||||
"src/data/contactMatch.js",
|
"src/data/contactMatch.js",
|
||||||
"src/data/iconOrientation.js",
|
"src/data/iconOrientation.js",
|
||||||
|
"src/data/idSwitches.js",
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
"src/data/modelVisualAnchor.js",
|
"src/data/modelVisualAnchor.js",
|
||||||
@@ -876,6 +880,7 @@
|
|||||||
"src/data/cockpitContactDot.js",
|
"src/data/cockpitContactDot.js",
|
||||||
"src/data/contactMatch.js",
|
"src/data/contactMatch.js",
|
||||||
"src/data/iconOrientation.js",
|
"src/data/iconOrientation.js",
|
||||||
|
"src/data/idSwitches.js",
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateCoordinator.js",
|
"src/data/layerStateCoordinator.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
@@ -931,6 +936,7 @@
|
|||||||
"exports": ["./layers/recent-imagery"],
|
"exports": ["./layers/recent-imagery"],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/layers/recentImagery/index.js",
|
"src/layers/recentImagery/index.js",
|
||||||
|
"src/layers/recentImagery/source.js",
|
||||||
"src/layers/recentImagery/model.js",
|
"src/layers/recentImagery/model.js",
|
||||||
"src/layers/recentImagery/catalog.js",
|
"src/layers/recentImagery/catalog.js",
|
||||||
"src/layers/recentImagery/rendering.js",
|
"src/layers/recentImagery/rendering.js",
|
||||||
@@ -1185,14 +1191,22 @@
|
|||||||
"street-level-layer": {
|
"street-level-layer": {
|
||||||
"exports": [
|
"exports": [
|
||||||
"./layers/street-level",
|
"./layers/street-level",
|
||||||
|
"./layers/street-level/mapillary",
|
||||||
"./layers/street-level/mapillary/source"
|
"./layers/street-level/mapillary/source"
|
||||||
],
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/layers/streetLevel/index.js",
|
"src/layers/streetLevel/index.js",
|
||||||
|
"src/layers/streetLevel/credits.js",
|
||||||
|
"src/layers/streetLevel/mapillaryProvider.js",
|
||||||
"src/layers/streetLevel/viewerHost.js",
|
"src/layers/streetLevel/viewerHost.js",
|
||||||
"src/layers/streetLevel/cameraFraming.js",
|
"src/layers/streetLevel/cameraFraming.js",
|
||||||
"src/layers/streetLevel/marker.js",
|
"src/layers/streetLevel/marker.js",
|
||||||
"src/layers/streetLevel/view.js",
|
"src/layers/streetLevel/view.js",
|
||||||
|
"src/layers/streetLevel/uiState.js",
|
||||||
|
"src/layers/streetLevel/pickRouter.js",
|
||||||
|
"src/data/idSwitches.js",
|
||||||
|
"src/layers/streetLevel/params.js",
|
||||||
|
"src/layers/streetLevel/registry.js",
|
||||||
"src/layers/streetLevel/filter.js",
|
"src/layers/streetLevel/filter.js",
|
||||||
"src/layers/streetLevel/policy.js",
|
"src/layers/streetLevel/policy.js",
|
||||||
"src/layers/streetLevel/state.js",
|
"src/layers/streetLevel/state.js",
|
||||||
@@ -1235,6 +1249,7 @@
|
|||||||
"exports": ["./layers/radio"],
|
"exports": ["./layers/radio"],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/data/iconOrientation.js",
|
"src/data/iconOrientation.js",
|
||||||
|
"src/data/idSwitches.js",
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
"src/data/radioCountry.js",
|
"src/data/radioCountry.js",
|
||||||
@@ -1343,6 +1358,7 @@
|
|||||||
"modules": [
|
"modules": [
|
||||||
"src/voice/realtimeController.js",
|
"src/voice/realtimeController.js",
|
||||||
"src/voice/realtimeBackend.js",
|
"src/voice/realtimeBackend.js",
|
||||||
|
"src/voice/cloudVoiceAuth.js",
|
||||||
"src/voice/control.js",
|
"src/voice/control.js",
|
||||||
"src/voice/layerSummary.js",
|
"src/voice/layerSummary.js",
|
||||||
"src/voice/voiceCost.js",
|
"src/voice/voiceCost.js",
|
||||||
@@ -1420,6 +1436,7 @@
|
|||||||
"src/annotations/worldAnnotationRenderer.js",
|
"src/annotations/worldAnnotationRenderer.js",
|
||||||
"src/app/catalog.js",
|
"src/app/catalog.js",
|
||||||
"src/app/constructCatalog.js",
|
"src/app/constructCatalog.js",
|
||||||
|
"src/app/sourceComposition.js",
|
||||||
"src/app/controls.js",
|
"src/app/controls.js",
|
||||||
"src/app/data.js",
|
"src/app/data.js",
|
||||||
"src/app/errors.js",
|
"src/app/errors.js",
|
||||||
@@ -1521,6 +1538,7 @@
|
|||||||
"src/data/installationFeedback.js",
|
"src/data/installationFeedback.js",
|
||||||
"src/data/issPass.js",
|
"src/data/issPass.js",
|
||||||
"src/data/labelArbiter.js",
|
"src/data/labelArbiter.js",
|
||||||
|
"src/data/idSwitches.js",
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateCoordinator.js",
|
"src/data/layerStateCoordinator.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
@@ -1957,6 +1975,7 @@
|
|||||||
"src/voice/resultDisplay.js",
|
"src/voice/resultDisplay.js",
|
||||||
"src/voice/gevRealtime.js",
|
"src/voice/gevRealtime.js",
|
||||||
"src/voice/layerSummary.js",
|
"src/voice/layerSummary.js",
|
||||||
|
"src/voice/cloudVoiceAuth.js",
|
||||||
"src/voice/realtimeBackend.js",
|
"src/voice/realtimeBackend.js",
|
||||||
"src/voice/realtimeController.js",
|
"src/voice/realtimeController.js",
|
||||||
"src/voice/voiceCost.js",
|
"src/voice/voiceCost.js",
|
||||||
@@ -1977,6 +1996,7 @@
|
|||||||
"src/annotations/drawTool.js",
|
"src/annotations/drawTool.js",
|
||||||
"src/data/routeSteps.js",
|
"src/data/routeSteps.js",
|
||||||
"src/layers/directions/index.js",
|
"src/layers/directions/index.js",
|
||||||
|
"src/layers/directions/source.js",
|
||||||
"src/data/directions.js",
|
"src/data/directions.js",
|
||||||
"src/app/layers/directions.js",
|
"src/app/layers/directions.js",
|
||||||
"src/search/coordinateGeocoder.js",
|
"src/search/coordinateGeocoder.js",
|
||||||
@@ -2111,6 +2131,7 @@
|
|||||||
"src/ui/localSdrPresentation.js",
|
"src/ui/localSdrPresentation.js",
|
||||||
"src/app/layers/recentImagery.js",
|
"src/app/layers/recentImagery.js",
|
||||||
"src/layers/recentImagery/index.js",
|
"src/layers/recentImagery/index.js",
|
||||||
|
"src/layers/recentImagery/source.js",
|
||||||
"src/layers/recentImagery/model.js",
|
"src/layers/recentImagery/model.js",
|
||||||
"src/layers/recentImagery/catalog.js",
|
"src/layers/recentImagery/catalog.js",
|
||||||
"src/layers/recentImagery/rendering.js",
|
"src/layers/recentImagery/rendering.js",
|
||||||
@@ -2127,6 +2148,13 @@
|
|||||||
"src/ui/streetLevelControls.js",
|
"src/ui/streetLevelControls.js",
|
||||||
"src/ui/streetLevelPresentation.js",
|
"src/ui/streetLevelPresentation.js",
|
||||||
"src/layers/streetLevel/index.js",
|
"src/layers/streetLevel/index.js",
|
||||||
|
"src/layers/streetLevel/uiState.js",
|
||||||
|
"src/layers/streetLevel/pickRouter.js",
|
||||||
|
"src/layers/streetLevel/params.js",
|
||||||
|
"src/layers/streetLevel/registry.js",
|
||||||
|
"scripts/qa-street-level.mjs",
|
||||||
|
"src/layers/streetLevel/credits.js",
|
||||||
|
"src/layers/streetLevel/mapillaryProvider.js",
|
||||||
"src/layers/streetLevel/viewerHost.js",
|
"src/layers/streetLevel/viewerHost.js",
|
||||||
"src/layers/streetLevel/cameraFraming.js",
|
"src/layers/streetLevel/cameraFraming.js",
|
||||||
"src/layers/streetLevel/marker.js",
|
"src/layers/streetLevel/marker.js",
|
||||||
@@ -2197,6 +2225,7 @@
|
|||||||
"src/annotations/resolver.js",
|
"src/annotations/resolver.js",
|
||||||
"src/app/catalog.js",
|
"src/app/catalog.js",
|
||||||
"src/app/constructCatalog.js",
|
"src/app/constructCatalog.js",
|
||||||
|
"src/app/sourceComposition.js",
|
||||||
"src/layers/wind/index.js",
|
"src/layers/wind/index.js",
|
||||||
"src/layers/wind/model.js",
|
"src/layers/wind/model.js",
|
||||||
"src/layers/wind/fields.js",
|
"src/layers/wind/fields.js",
|
||||||
@@ -2256,6 +2285,7 @@
|
|||||||
"src/data/issPass.js",
|
"src/data/issPass.js",
|
||||||
"src/data/satellitePass.js",
|
"src/data/satellitePass.js",
|
||||||
"src/data/labelArbiter.js",
|
"src/data/labelArbiter.js",
|
||||||
|
"src/data/idSwitches.js",
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
"src/data/localGeojson.js",
|
"src/data/localGeojson.js",
|
||||||
@@ -2546,6 +2576,7 @@
|
|||||||
"src/services/groundFloor.js",
|
"src/services/groundFloor.js",
|
||||||
"src/data/routeSteps.js",
|
"src/data/routeSteps.js",
|
||||||
"src/layers/directions/index.js",
|
"src/layers/directions/index.js",
|
||||||
|
"src/layers/directions/source.js",
|
||||||
"src/services/terrainHeights.js",
|
"src/services/terrainHeights.js",
|
||||||
"src/data/terrainHeights.js",
|
"src/data/terrainHeights.js",
|
||||||
"src/data/groundFloor.js",
|
"src/data/groundFloor.js",
|
||||||
@@ -2634,12 +2665,20 @@
|
|||||||
"src/data/modelEligibility.js",
|
"src/data/modelEligibility.js",
|
||||||
"src/app/layers/recentImagery.js",
|
"src/app/layers/recentImagery.js",
|
||||||
"src/layers/recentImagery/index.js",
|
"src/layers/recentImagery/index.js",
|
||||||
|
"src/layers/recentImagery/source.js",
|
||||||
"src/layers/recentImagery/model.js",
|
"src/layers/recentImagery/model.js",
|
||||||
"src/layers/recentImagery/catalog.js",
|
"src/layers/recentImagery/catalog.js",
|
||||||
"src/layers/recentImagery/rendering.js",
|
"src/layers/recentImagery/rendering.js",
|
||||||
"src/layers/recentImagery/thumbnails.js",
|
"src/layers/recentImagery/thumbnails.js",
|
||||||
"src/maps/imageryHost.js",
|
"src/maps/imageryHost.js",
|
||||||
"src/layers/streetLevel/index.js",
|
"src/layers/streetLevel/index.js",
|
||||||
|
"src/layers/streetLevel/uiState.js",
|
||||||
|
"src/layers/streetLevel/pickRouter.js",
|
||||||
|
"src/layers/streetLevel/params.js",
|
||||||
|
"src/layers/streetLevel/registry.js",
|
||||||
|
"scripts/qa-street-level.mjs",
|
||||||
|
"src/layers/streetLevel/credits.js",
|
||||||
|
"src/layers/streetLevel/mapillaryProvider.js",
|
||||||
"src/layers/streetLevel/viewerHost.js",
|
"src/layers/streetLevel/viewerHost.js",
|
||||||
"src/layers/streetLevel/cameraFraming.js",
|
"src/layers/streetLevel/cameraFraming.js",
|
||||||
"src/layers/streetLevel/marker.js",
|
"src/layers/streetLevel/marker.js",
|
||||||
@@ -2766,6 +2805,7 @@
|
|||||||
"exports": ["./ui/share-restoration"],
|
"exports": ["./ui/share-restoration"],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/data/installationFeedback.js",
|
"src/data/installationFeedback.js",
|
||||||
|
"src/data/idSwitches.js",
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateCoordinator.js",
|
"src/data/layerStateCoordinator.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
@@ -2878,9 +2918,27 @@
|
|||||||
],
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
|
"directions-source": {
|
||||||
|
"exports": ["./layers/directions/source"],
|
||||||
|
"modules": ["src/layers/directions/source.js"],
|
||||||
|
"external": []
|
||||||
|
},
|
||||||
|
"recent-imagery-source": {
|
||||||
|
"exports": ["./layers/recent-imagery/source"],
|
||||||
|
"modules": [
|
||||||
|
"src/layers/recentImagery/source.js",
|
||||||
|
"src/layers/recentImagery/catalog.js",
|
||||||
|
"src/layers/recentImagery/model.js"
|
||||||
|
],
|
||||||
|
"external": []
|
||||||
|
},
|
||||||
"directions-layer": {
|
"directions-layer": {
|
||||||
"exports": ["./layers/directions"],
|
"exports": ["./layers/directions"],
|
||||||
"modules": ["src/layers/directions/index.js", "src/data/routeSteps.js"],
|
"modules": [
|
||||||
|
"src/layers/directions/index.js",
|
||||||
|
"src/layers/directions/source.js",
|
||||||
|
"src/data/routeSteps.js"
|
||||||
|
],
|
||||||
"external": ["cesium"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
"radio-browser-source": {
|
"radio-browser-source": {
|
||||||
@@ -3267,6 +3325,7 @@
|
|||||||
"src/tools/queries/brief.js",
|
"src/tools/queries/brief.js",
|
||||||
"src/tools/queries/maritime.js",
|
"src/tools/queries/maritime.js",
|
||||||
"src/tools/queries/share.js",
|
"src/tools/queries/share.js",
|
||||||
|
"src/data/idSwitches.js",
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
"src/sources/gbfsStations.js",
|
"src/sources/gbfsStations.js",
|
||||||
@@ -3389,6 +3448,7 @@
|
|||||||
"exports": ["./view"],
|
"exports": ["./view"],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/view/index.js",
|
"src/view/index.js",
|
||||||
|
"src/data/idSwitches.js",
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateTokenReservations.json"
|
"src/data/layerStateTokenReservations.json"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Check auth-mode keyboard focus with the real settings template and controls.
|
||||||
|
* Run: node --test --test-reporter=spec scripts/qa-voice-auth-focus.mjs
|
||||||
|
* Sign-in responses are fixtures. No credentials or live login are used.
|
||||||
|
*/
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
import { after, before, test } from 'node:test';
|
||||||
|
import puppeteer from 'puppeteer';
|
||||||
|
|
||||||
|
const root = new URL('../', import.meta.url);
|
||||||
|
const template = await readFile(
|
||||||
|
new URL('src/ui/templates/provider-settings.html', root),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const assets = new Set([
|
||||||
|
'/src/keySetup.js',
|
||||||
|
'/src/voice/cloudVoiceAuth.js',
|
||||||
|
'/src/ui/surfaceKeyboard.js',
|
||||||
|
'/src/ui/styles/provider-settings.css',
|
||||||
|
]);
|
||||||
|
const observations = [];
|
||||||
|
let server;
|
||||||
|
let browser;
|
||||||
|
let origin;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
server = createServer(async (req, res) => {
|
||||||
|
const pathname = new URL(req.url, 'http://localhost').pathname;
|
||||||
|
if (pathname === '/') {
|
||||||
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
|
res.end(`<!doctype html><html><head><meta charset="utf-8">
|
||||||
|
<title>Voice auth keyboard focus test</title>
|
||||||
|
<link rel="stylesheet" href="/src/ui/styles/provider-settings.css">
|
||||||
|
</head><body>${template}</body></html>`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!assets.has(pathname)) {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
res.setHeader(
|
||||||
|
'Content-Type',
|
||||||
|
pathname.endsWith('.css') ? 'text/css' : 'text/javascript',
|
||||||
|
);
|
||||||
|
res.end(await readFile(new URL(pathname.slice(1), root)));
|
||||||
|
} catch {
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.end('Could not load the test asset');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(0, '127.0.0.1', resolve);
|
||||||
|
});
|
||||||
|
origin = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
const chrome = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||||
|
browser = await puppeteer.launch({
|
||||||
|
headless: true,
|
||||||
|
executablePath:
|
||||||
|
process.env.PUPPETEER_EXECUTABLE_PATH ||
|
||||||
|
(existsSync(chrome) ? chrome : await puppeteer.executablePath()),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await browser?.close();
|
||||||
|
if (server?.listening) await new Promise((resolve) => server.close(resolve));
|
||||||
|
const output = new URL('.gev-logs/voice-auth-focus/', root);
|
||||||
|
await mkdir(output, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
new URL('results.json', output),
|
||||||
|
`${JSON.stringify(observations, null, 2)}\n`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const scenarios = [
|
||||||
|
{
|
||||||
|
name: 'OAuth to API key',
|
||||||
|
initial: 'oauth',
|
||||||
|
expected: 'api-key',
|
||||||
|
flow: 'unused',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'API key to existing OAuth',
|
||||||
|
initial: 'api-key',
|
||||||
|
expected: 'oauth',
|
||||||
|
flow: 'existing',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'API key to OAuth ready at login launch',
|
||||||
|
initial: 'api-key',
|
||||||
|
expected: 'oauth',
|
||||||
|
flow: 'login-ready',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'API key to OAuth ready after polling',
|
||||||
|
initial: 'api-key',
|
||||||
|
expected: 'oauth',
|
||||||
|
flow: 'poll-ready',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function mountSettings(page, scenario) {
|
||||||
|
await page.goto(`${origin}/?setup=1`, {
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
});
|
||||||
|
await page.evaluate(async ({ initial, flow }) => {
|
||||||
|
localStorage.clear();
|
||||||
|
localStorage.setItem('godsEyeView.voice.cloudAuth', initial);
|
||||||
|
window.__focusTestCalls = [];
|
||||||
|
let statusReads = 0;
|
||||||
|
const fetchImpl = async (url) => {
|
||||||
|
window.__focusTestCalls.push(url);
|
||||||
|
let payload;
|
||||||
|
let status = 200;
|
||||||
|
if (url === '/api/setup/status') {
|
||||||
|
payload = {
|
||||||
|
setCount: 0,
|
||||||
|
total: 1,
|
||||||
|
keys: [
|
||||||
|
{
|
||||||
|
id: 'openai',
|
||||||
|
title: 'OPENAI',
|
||||||
|
tier: 'metered',
|
||||||
|
set: false,
|
||||||
|
unlocks: 'Voice control',
|
||||||
|
envVars: ['OPENAI_API_KEY'],
|
||||||
|
getUrl: 'https://example.invalid/',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} else if (url === '/api/realtime/oauth-status') {
|
||||||
|
statusReads += 1;
|
||||||
|
if (flow === 'deferred') {
|
||||||
|
return await new Promise((resolve) => {
|
||||||
|
window.__resolveOAuthStatus = (available) =>
|
||||||
|
resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ available, error: 'Fixture failure' }),
|
||||||
|
{
|
||||||
|
status: available ? 200 : 503,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
payload = {
|
||||||
|
available:
|
||||||
|
flow === 'existing' || (flow === 'poll-ready' && statusReads > 1),
|
||||||
|
};
|
||||||
|
} else if (url === '/api/realtime/oauth-login') {
|
||||||
|
const available = flow === 'login-ready';
|
||||||
|
status = available ? 200 : 202;
|
||||||
|
payload = { available, pending: !available, started: !available };
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unexpected request: ${url}`);
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify(payload), { status });
|
||||||
|
};
|
||||||
|
const { initKeySetup } = await import('/src/keySetup.js');
|
||||||
|
window.__focusTestSettings = await initKeySetup({ fetchImpl });
|
||||||
|
}, scenario);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
document.getElementById('key-setup').classList.contains('visible') &&
|
||||||
|
document.activeElement.matches('input[data-env-var]'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const scenario of scenarios) {
|
||||||
|
for (const key of ['Enter', 'Space', 'Mouse click']) {
|
||||||
|
test(`${scenario.name} keeps keyboard focus after ${key}`, async (t) => {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
try {
|
||||||
|
await mountSettings(page, scenario);
|
||||||
|
|
||||||
|
if (key !== 'Mouse click') {
|
||||||
|
// Tab from the initial key field to the real auth button.
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(() =>
|
||||||
|
document.activeElement.matches('[data-cloud-voice-auth-toggle]'),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
'Keyboard navigation must reach the auth toggle before activation',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const toggle = document.querySelector(
|
||||||
|
'[data-cloud-voice-auth-toggle]',
|
||||||
|
);
|
||||||
|
window.__originalFocusToggle = toggle;
|
||||||
|
window.__focusAtActivation = null;
|
||||||
|
toggle.addEventListener(
|
||||||
|
'click',
|
||||||
|
() => {
|
||||||
|
window.__focusAtActivation = document.activeElement === toggle;
|
||||||
|
},
|
||||||
|
{ capture: true, once: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (key === 'Mouse click') {
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
getComputedStyle(document.getElementById('key-setup')).opacity ===
|
||||||
|
'1',
|
||||||
|
);
|
||||||
|
await page.click('[data-cloud-voice-auth-toggle]');
|
||||||
|
} else {
|
||||||
|
await page.keyboard.press(key);
|
||||||
|
}
|
||||||
|
await page.waitForFunction(
|
||||||
|
(expected) =>
|
||||||
|
localStorage.getItem('godsEyeView.voice.cloudAuth') === expected &&
|
||||||
|
!document.querySelector('[data-cloud-voice-auth-toggle]')
|
||||||
|
.disabled &&
|
||||||
|
document
|
||||||
|
.querySelector('[data-cloud-voice-auth-toggle]')
|
||||||
|
.getAttribute('aria-disabled') !== 'true',
|
||||||
|
{},
|
||||||
|
scenario.expected,
|
||||||
|
);
|
||||||
|
const selection = await page.evaluate(() => ({
|
||||||
|
focusOnToggleAtActivation: window.__focusAtActivation,
|
||||||
|
focusOnToggle:
|
||||||
|
document.activeElement ===
|
||||||
|
document.querySelector('[data-cloud-voice-auth-toggle]'),
|
||||||
|
activeTag: document.activeElement.tagName,
|
||||||
|
originalToggleConnected: window.__originalFocusToggle.isConnected,
|
||||||
|
requests: window.__focusTestCalls,
|
||||||
|
}));
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
const nextTab = await page.evaluate(() => ({
|
||||||
|
focusOnSave: document.activeElement.matches('[data-key-setup-apply]'),
|
||||||
|
activeLabel:
|
||||||
|
document.activeElement.getAttribute('aria-label') ||
|
||||||
|
document.activeElement.textContent.trim(),
|
||||||
|
}));
|
||||||
|
const observed = { scenario: scenario.name, key, selection, nextTab };
|
||||||
|
observations.push(observed);
|
||||||
|
t.diagnostic(JSON.stringify(observed));
|
||||||
|
assert.equal(
|
||||||
|
selection.focusOnToggle,
|
||||||
|
true,
|
||||||
|
'Auth selection must keep focus on its usable toggle',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
nextTab.focusOnSave,
|
||||||
|
true,
|
||||||
|
'Tab must continue to SAVE KEYS rather than restart at Close',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const available of [true, false]) {
|
||||||
|
test(`pending OAuth ${available ? 'success' : 'failure'} blocks repeats without moving focus or losing a key draft`, async () => {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
try {
|
||||||
|
await mountSettings(page, { initial: 'api-key', flow: 'deferred' });
|
||||||
|
await page.type('input[data-env-var]', 'test-draft-only');
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => typeof window.__resolveOAuthStatus === 'function',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(() =>
|
||||||
|
document.activeElement.matches('[data-cloud-voice-auth-toggle]'),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.keyboard.press('Space');
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
window.__focusTestCalls.filter(
|
||||||
|
(url) => url === '/api/realtime/oauth-status',
|
||||||
|
).length,
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(() =>
|
||||||
|
document.activeElement.matches('[data-key-setup-apply]'),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await page.evaluate(
|
||||||
|
(available) => window.__resolveOAuthStatus(available),
|
||||||
|
available,
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
document
|
||||||
|
.querySelector('[data-cloud-voice-auth-toggle]')
|
||||||
|
.getAttribute('aria-disabled') === 'false',
|
||||||
|
);
|
||||||
|
const observed = await page.evaluate(() => ({
|
||||||
|
mode: localStorage.getItem('godsEyeView.voice.cloudAuth'),
|
||||||
|
focusOnSave: document.activeElement.matches('[data-key-setup-apply]'),
|
||||||
|
draft: document.querySelector('input[data-env-var]').value,
|
||||||
|
requests: window.__focusTestCalls,
|
||||||
|
}));
|
||||||
|
observations.push({ scenario: 'pending OAuth', available, observed });
|
||||||
|
assert.equal(observed.mode, available ? 'oauth' : 'api-key');
|
||||||
|
assert.equal(
|
||||||
|
observed.focusOnSave,
|
||||||
|
true,
|
||||||
|
'Completing a request must not take focus from another control',
|
||||||
|
);
|
||||||
|
assert.equal(observed.draft, 'test-draft-only');
|
||||||
|
} finally {
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Live Realtime auth acceptance with a prerecorded Chromium microphone.
|
||||||
|
* Run against the local dev server after configuring the selected credential:
|
||||||
|
* node scripts/qa-voice-auth.mjs http://127.0.0.1:4189 oauth microphone.wav
|
||||||
|
* Use a short spoken reply request with three seconds of leading silence.
|
||||||
|
* Saves only usage, transport metadata, and screenshots; never credentials.
|
||||||
|
*/
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import puppeteer from 'puppeteer';
|
||||||
|
|
||||||
|
const root = fileURLToPath(new URL('../', import.meta.url));
|
||||||
|
const url = new URL(process.argv[2] || 'http://127.0.0.1:4189');
|
||||||
|
const auth = process.argv[3] || 'oauth';
|
||||||
|
assert.ok(['oauth', 'api-key'].includes(auth), 'Choose oauth or api-key');
|
||||||
|
assert.ok(
|
||||||
|
['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname),
|
||||||
|
'Use a local server',
|
||||||
|
);
|
||||||
|
assert.ok(process.argv[4], 'Provide a prerecorded microphone WAV');
|
||||||
|
const wav = path.resolve(process.argv[4]);
|
||||||
|
assert.ok(fs.existsSync(wav), 'Microphone WAV must exist');
|
||||||
|
url.searchParams.set('welcome', '0');
|
||||||
|
url.searchParams.set('setup', '1');
|
||||||
|
const output = path.join(root, '.gev-logs', 'voice-auth');
|
||||||
|
fs.mkdirSync(output, { recursive: true });
|
||||||
|
const executablePath =
|
||||||
|
process.env.PUPPETEER_EXECUTABLE_PATH ||
|
||||||
|
(fs.existsSync('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome')
|
||||||
|
? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
|
||||||
|
: puppeteer.executablePath());
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: true,
|
||||||
|
executablePath,
|
||||||
|
args: [
|
||||||
|
'--no-sandbox',
|
||||||
|
'--disable-setuid-sandbox',
|
||||||
|
'--use-fake-device-for-media-stream',
|
||||||
|
`--use-file-for-fake-audio-capture=${wav}%noloop`,
|
||||||
|
'--autoplay-policy=no-user-gesture-required',
|
||||||
|
'--disable-background-timer-throttling',
|
||||||
|
'--window-size=1440,900',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result = {
|
||||||
|
ok: false,
|
||||||
|
auth,
|
||||||
|
input: 'prerecorded microphone',
|
||||||
|
mints: [],
|
||||||
|
negotiations: [],
|
||||||
|
};
|
||||||
|
let page;
|
||||||
|
const readSession = () =>
|
||||||
|
page.evaluate(async () => {
|
||||||
|
const voice = window.__godsEyeView.voiceCommands;
|
||||||
|
const stats = voice.pc ? [...(await voice.pc.getStats()).values()] : [];
|
||||||
|
const input = stats.find(
|
||||||
|
(entry) => entry.type === 'outbound-rtp' && entry.kind === 'audio',
|
||||||
|
);
|
||||||
|
const source = stats.find(
|
||||||
|
(entry) => entry.type === 'media-source' && entry.kind === 'audio',
|
||||||
|
);
|
||||||
|
const audio = stats.find(
|
||||||
|
(entry) => entry.type === 'inbound-rtp' && entry.kind === 'audio',
|
||||||
|
);
|
||||||
|
const meter = document.getElementById('gev-voice-cost-value');
|
||||||
|
return {
|
||||||
|
...window.__voiceAuthEvidence,
|
||||||
|
connection: voice.pc?.connectionState,
|
||||||
|
microphoneEnabled: voice.stream
|
||||||
|
?.getAudioTracks()
|
||||||
|
.every((track) => track.enabled),
|
||||||
|
inputBytes: input?.bytesSent || 0,
|
||||||
|
inputAudioEnergy: source?.totalAudioEnergy ?? null,
|
||||||
|
outputBytes: audio?.bytesReceived || 0,
|
||||||
|
outputAudioEnergy: audio?.totalAudioEnergy || 0,
|
||||||
|
playbackTime: voice.audioEl?.currentTime || 0,
|
||||||
|
meter: {
|
||||||
|
text: meter?.textContent,
|
||||||
|
usage: meter?.title,
|
||||||
|
visible: Boolean(meter?.getClientRects().length),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await browser
|
||||||
|
.defaultBrowserContext()
|
||||||
|
.overridePermissions(url.origin, ['microphone']);
|
||||||
|
page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1440, height: 900 });
|
||||||
|
page.on('response', (response) => {
|
||||||
|
const route = new URL(response.url()).pathname;
|
||||||
|
if (route === '/api/realtime/token')
|
||||||
|
result.mints.push({
|
||||||
|
status: response.status(),
|
||||||
|
auth: response.headers()['x-gev-voice-auth'],
|
||||||
|
model: response.headers()['x-gev-voice-model'],
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
route === '/v1/realtime/calls' &&
|
||||||
|
response.request().method() === 'POST'
|
||||||
|
)
|
||||||
|
result.negotiations.push({ status: response.status() });
|
||||||
|
});
|
||||||
|
// Observe the application's real transport. Do not replace fetch or replies.
|
||||||
|
await page.evaluateOnNewDocument(() => {
|
||||||
|
window.__voiceAuthEvidence = {
|
||||||
|
speechStopped: 0,
|
||||||
|
transcripts: [],
|
||||||
|
replies: [],
|
||||||
|
responses: [],
|
||||||
|
errors: [],
|
||||||
|
eventCounts: {},
|
||||||
|
};
|
||||||
|
const create = RTCPeerConnection.prototype.createDataChannel;
|
||||||
|
RTCPeerConnection.prototype.createDataChannel = function (...args) {
|
||||||
|
const channel = create.apply(this, args);
|
||||||
|
channel.addEventListener('message', ({ data }) => {
|
||||||
|
let event;
|
||||||
|
try {
|
||||||
|
event = JSON.parse(data);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const evidence = window.__voiceAuthEvidence;
|
||||||
|
evidence.eventCounts[event.type] =
|
||||||
|
(evidence.eventCounts[event.type] || 0) + 1;
|
||||||
|
if (event.type === 'input_audio_buffer.speech_stopped')
|
||||||
|
evidence.speechStopped += 1;
|
||||||
|
if (
|
||||||
|
event.type === 'conversation.item.input_audio_transcription.completed'
|
||||||
|
)
|
||||||
|
evidence.transcripts.push(event.transcript);
|
||||||
|
if (event.type === 'response.output_audio_transcript.done')
|
||||||
|
evidence.replies.push(event.transcript);
|
||||||
|
if (event.type === 'response.done')
|
||||||
|
evidence.responses.push({
|
||||||
|
status: event.response?.status,
|
||||||
|
inputTokens: event.response?.usage?.input_tokens,
|
||||||
|
outputTokens: event.response?.usage?.output_tokens,
|
||||||
|
});
|
||||||
|
if (event.type === 'error')
|
||||||
|
evidence.errors.push({ code: event.error?.code });
|
||||||
|
});
|
||||||
|
return channel;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView?.voiceCommands &&
|
||||||
|
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||||
|
{ timeout: 45_000 },
|
||||||
|
);
|
||||||
|
await page.waitForSelector('[data-cloud-voice-auth-toggle]', {
|
||||||
|
visible: true,
|
||||||
|
});
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
getComputedStyle(document.getElementById('key-setup')).opacity === '1',
|
||||||
|
);
|
||||||
|
const selected = await page.evaluate(
|
||||||
|
() => localStorage.getItem('godsEyeView.voice.cloudAuth') || 'api-key',
|
||||||
|
);
|
||||||
|
if (selected !== auth) {
|
||||||
|
await page.click('[data-cloud-voice-auth-toggle]');
|
||||||
|
await page.waitForFunction(
|
||||||
|
(mode) => localStorage.getItem('godsEyeView.voice.cloudAuth') === mode,
|
||||||
|
{ timeout: 125_000 },
|
||||||
|
auth,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await page.click('[data-key-setup-close]');
|
||||||
|
await page.click('#gev-voice-button');
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView.voiceCommands.dc?.readyState === 'open' ||
|
||||||
|
window.__godsEyeView.voiceCommands.status === 'error',
|
||||||
|
{ timeout: 35_000 },
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(
|
||||||
|
() => window.__godsEyeView.voiceCommands.dc?.readyState,
|
||||||
|
),
|
||||||
|
'open',
|
||||||
|
'Realtime channel must open',
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__voiceAuthEvidence.speechStopped > 0 &&
|
||||||
|
window.__voiceAuthEvidence.responses.some(
|
||||||
|
(response) =>
|
||||||
|
response.status === 'completed' && response.outputTokens > 0,
|
||||||
|
),
|
||||||
|
{ timeout: 45_000 },
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__voiceAuthEvidence.eventCounts['output_audio_buffer.stopped'] > 0,
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
);
|
||||||
|
result.session = await readSession();
|
||||||
|
assert.ok(
|
||||||
|
result.session.inputBytes > 0 && result.session.outputBytes > 0,
|
||||||
|
'Audio must flow in both directions',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.session.outputAudioEnergy > 0 && result.session.playbackTime > 0,
|
||||||
|
'Reply audio must play',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
result.session.meter.text === 'COST UNKNOWN',
|
||||||
|
auth === 'oauth',
|
||||||
|
'Use the correct accounting display',
|
||||||
|
);
|
||||||
|
await page.screenshot({ path: path.join(output, `${auth}-usage.png`) });
|
||||||
|
await page.click('#key-setup-chip');
|
||||||
|
await page.waitForSelector('[data-cloud-voice-auth-toggle]', {
|
||||||
|
visible: true,
|
||||||
|
});
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
getComputedStyle(document.getElementById('key-setup')).opacity === '1',
|
||||||
|
);
|
||||||
|
await page.screenshot({ path: path.join(output, `${auth}-settings.png`) });
|
||||||
|
await page.click('[data-key-setup-close]');
|
||||||
|
result.stopped = await page.evaluate(() => {
|
||||||
|
const voice = window.__godsEyeView.voiceCommands;
|
||||||
|
const pc = voice.pc;
|
||||||
|
const tracks = voice.stream?.getTracks() || [];
|
||||||
|
voice.session.stop();
|
||||||
|
return (
|
||||||
|
pc.connectionState === 'closed' &&
|
||||||
|
tracks.every((track) => track.readyState === 'ended')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
assert.ok(result.stopped, 'Stop must close the peer and microphone tracks');
|
||||||
|
await page.click('#gev-voice-button');
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView.voiceCommands.dc?.readyState === 'open' ||
|
||||||
|
window.__godsEyeView.voiceCommands.status === 'error',
|
||||||
|
{ timeout: 35_000 },
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(
|
||||||
|
() => window.__godsEyeView.voiceCommands.dc?.readyState,
|
||||||
|
),
|
||||||
|
'open',
|
||||||
|
'Reconnect must work',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
result.mints.length,
|
||||||
|
2,
|
||||||
|
'Reconnect must request a fresh client secret',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.mints.every(
|
||||||
|
(mint) =>
|
||||||
|
mint.status === 200 &&
|
||||||
|
mint.auth === (auth === 'oauth' ? 'codex-oauth' : 'env'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.ok(result.negotiations.every((entry) => entry.status === 201));
|
||||||
|
result.ok = true;
|
||||||
|
} catch (error) {
|
||||||
|
result.error = error.message;
|
||||||
|
if (page) {
|
||||||
|
result.session = await readSession().catch(() => null);
|
||||||
|
result.detail = await page
|
||||||
|
.$eval('#gev-voice-detail', (element) => element.textContent)
|
||||||
|
.catch(() => null);
|
||||||
|
await page
|
||||||
|
.screenshot({ path: path.join(output, `${auth}-failure.png`) })
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await page
|
||||||
|
?.evaluate(() => window.__godsEyeView?.voiceCommands?.session?.stop())
|
||||||
|
.catch(() => {});
|
||||||
|
await browser.close();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(output, `${auth}-result.json`),
|
||||||
|
`${JSON.stringify(result, null, 2)}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
if (!result.ok) process.exitCode = 1;
|
||||||
@@ -176,7 +176,9 @@ export function buildCapabilitySummary(
|
|||||||
? 'Google Photorealistic 3D Tiles through Cesium ion; Bing and world-terrain stacks available'
|
? 'Google Photorealistic 3D Tiles through Cesium ion; Bing and world-terrain stacks available'
|
||||||
: 'Esri World Imagery (keyless satellite basemap) with keyless terrain',
|
: 'Esri World Imagery (keyless satellite basemap) with keyless terrain',
|
||||||
flights,
|
flights,
|
||||||
voice: configured('OPENAI_API_KEY') ? 'available' : 'off until an OpenAI key is added',
|
voice: configured('OPENAI_API_KEY')
|
||||||
|
? 'available'
|
||||||
|
: 'off until an OpenAI key is added, or a Codex ChatGPT sign-in is selected in Provider Settings (experimental)',
|
||||||
vessels: configured('AISSTREAM_API_KEY') ? 'live AISStream feed' : 'off until an AISStream key is added',
|
vessels: configured('AISSTREAM_API_KEY') ? 'live AISStream feed' : 'off until an AISStream key is added',
|
||||||
fires: configured('FIRMS_MAP_KEY') ? 'live NASA FIRMS feed' : 'off until a FIRMS key is added',
|
fires: configured('FIRMS_MAP_KEY') ? 'live NASA FIRMS feed' : 'off until a FIRMS key is added',
|
||||||
traffic: configured('TOMTOM_API_KEY') ? 'live TomTom flow' : 'built-in traffic simulation',
|
traffic: configured('TOMTOM_API_KEY') ? 'live TomTom flow' : 'built-in traffic simulation',
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
import { defaultSourceRoot } from './common/source-root.js';
|
import { defaultSourceRoot } from './common/source-root.js';
|
||||||
import { handleHudSummary } from './openai/hud-summary.js';
|
import { handleHudSummary } from './openai/hud-summary.js';
|
||||||
import { createDebugLogHandler } from './openai/debug-log.js';
|
import { createDebugLogHandler } from './openai/debug-log.js';
|
||||||
import { createRealtimeTokenHandler } from './openai/realtime.js';
|
import {
|
||||||
|
createRealtimeOAuthLoginHandler,
|
||||||
|
createRealtimeOAuthStatusHandler,
|
||||||
|
createRealtimeTokenHandler,
|
||||||
|
} from './openai/realtime.js';
|
||||||
import { sameSiteGated } from './common/same-site.js';
|
import { sameSiteGated } from './common/same-site.js';
|
||||||
|
import { createCodexOAuthLogin } from './openai/codex-auth.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vite plugin: OpenAI Realtime ephemeral client secret.
|
* Vite plugin: OpenAI Realtime ephemeral client secret.
|
||||||
*
|
*
|
||||||
* Keeps OPENAI_API_KEY server-side while the browser connects to the
|
* Keeps the selected cloud credential server-side while the browser connects
|
||||||
* Realtime API over WebRTC with a short-lived secret.
|
* to the Realtime API over WebRTC with a short-lived secret.
|
||||||
*/
|
*/
|
||||||
function openAiRealtimeProxy({
|
function openAiRealtimeProxy({
|
||||||
sourceRoot = defaultSourceRoot,
|
sourceRoot = defaultSourceRoot,
|
||||||
annotationGuidance,
|
annotationGuidance,
|
||||||
realtime = {},
|
realtime = {},
|
||||||
} = {}) {
|
} = {}) {
|
||||||
|
const oauthLogin = createCodexOAuthLogin(realtime);
|
||||||
function install(middlewares) {
|
function install(middlewares) {
|
||||||
// Cost-bearing and log endpoints refuse cross-site browser requests
|
// Cost-bearing and log endpoints refuse cross-site browser requests
|
||||||
// (see server/providers/common/same-site.js and SECURITY.md).
|
// (see server/providers/common/same-site.js and SECURITY.md).
|
||||||
@@ -31,16 +37,29 @@ function openAiRealtimeProxy({
|
|||||||
createRealtimeTokenHandler({ ...realtime, annotationGuidance }),
|
createRealtimeTokenHandler({ ...realtime, annotationGuidance }),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
middlewares.use(
|
||||||
|
'/api/realtime/oauth-status',
|
||||||
|
sameSiteGated(createRealtimeOAuthStatusHandler({ oauthLogin })),
|
||||||
|
);
|
||||||
|
|
||||||
|
middlewares.use(
|
||||||
|
'/api/realtime/oauth-login',
|
||||||
|
sameSiteGated(createRealtimeOAuthLoginHandler({ oauthLogin })),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: 'openai-realtime-proxy',
|
name: 'openai-realtime-proxy',
|
||||||
configureServer(server) {
|
configureServer(server) {
|
||||||
install(server.middlewares);
|
install(server.middlewares);
|
||||||
|
server.httpServer?.once('close', oauthLogin.dispose);
|
||||||
},
|
},
|
||||||
configurePreviewServer(server) {
|
configurePreviewServer(server) {
|
||||||
install(server.middlewares);
|
install(server.middlewares);
|
||||||
|
server.httpServer?.once('close', oauthLogin.dispose);
|
||||||
},
|
},
|
||||||
|
closeBundle: oauthLogin.dispose,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,360 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import {
|
||||||
|
accessSync,
|
||||||
|
constants as fsConstants,
|
||||||
|
readFileSync,
|
||||||
|
statSync,
|
||||||
|
} from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
export function codexAuthPath({
|
||||||
|
environment = process.env,
|
||||||
|
home = os.homedir(),
|
||||||
|
} = {}) {
|
||||||
|
const explicit = String(environment.CODEX_AUTH_JSON || '').trim();
|
||||||
|
if (explicit) return path.resolve(explicit);
|
||||||
|
const codexHome = String(environment.CODEX_HOME || '').trim();
|
||||||
|
return path.join(
|
||||||
|
codexHome ? path.resolve(codexHome) : path.join(home, '.codex'),
|
||||||
|
'auth.json',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An optional operator lock that refuses the metered API-key voice lane. */
|
||||||
|
export function preferCodexOAuth(environment = process.env) {
|
||||||
|
if (!Object.hasOwn(environment, 'GEV_PREFER_CODEX_OAUTH')) return false;
|
||||||
|
const value = String(environment.GEV_PREFER_CODEX_OAUTH || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (['1', 'true', 'yes', 'on'].includes(value)) return true;
|
||||||
|
if (['0', 'false', 'no', 'off'].includes(value)) return false;
|
||||||
|
throw new Error(
|
||||||
|
'GEV_PREFER_CODEX_OAUTH must be true or false. Refusing to choose a voice auth mode.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function codexOAuthError(message, code = 'CODEX_OAUTH_UNAVAILABLE') {
|
||||||
|
const error = new Error(message);
|
||||||
|
error.code = code;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readCodexOAuthAccessToken({
|
||||||
|
authPath = codexAuthPath(),
|
||||||
|
readFile = readFileSync,
|
||||||
|
statFile = statSync,
|
||||||
|
nowMs = Date.now(),
|
||||||
|
} = {}) {
|
||||||
|
const unavailable =
|
||||||
|
'ChatGPT sign-in is unavailable. Sign in to Codex or the ChatGPT desktop app and try again.';
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(readFile(authPath, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
throw codexOAuthError(unavailable);
|
||||||
|
}
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
|
throw codexOAuthError(unavailable);
|
||||||
|
}
|
||||||
|
const mode = String(parsed.auth_mode || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const chatGptMode = mode
|
||||||
|
? mode === 'chatgpt' || mode === 'chatgptauthtokens'
|
||||||
|
: typeof parsed.OPENAI_API_KEY !== 'string';
|
||||||
|
const token = parsed.tokens?.access_token;
|
||||||
|
if (!chatGptMode || typeof token !== 'string' || !token.trim()) {
|
||||||
|
throw codexOAuthError(unavailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This only checks token shape and expiry. OpenAI verifies the signature when
|
||||||
|
// minting the short-lived Realtime credential; we never modify auth.json.
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (
|
||||||
|
parts.length !== 3 ||
|
||||||
|
parts.some((part) => !/^[A-Za-z0-9_-]+$/.test(part))
|
||||||
|
) {
|
||||||
|
throw codexOAuthError(unavailable);
|
||||||
|
}
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
||||||
|
} catch {
|
||||||
|
throw codexOAuthError(unavailable);
|
||||||
|
}
|
||||||
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||||
|
throw codexOAuthError(unavailable);
|
||||||
|
}
|
||||||
|
let expiresAtMs;
|
||||||
|
if (Object.hasOwn(payload, 'exp')) {
|
||||||
|
if (typeof payload.exp !== 'number' || !Number.isFinite(payload.exp)) {
|
||||||
|
throw codexOAuthError(unavailable);
|
||||||
|
}
|
||||||
|
expiresAtMs = payload.exp * 1000;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
expiresAtMs = statFile(authPath).mtimeMs + 60 * 60 * 1000;
|
||||||
|
} catch {
|
||||||
|
throw codexOAuthError(unavailable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= nowMs + 60_000) {
|
||||||
|
throw codexOAuthError(
|
||||||
|
'ChatGPT sign-in has expired. Re-authenticate in Provider Settings or run codex login and try again.',
|
||||||
|
'CODEX_OAUTH_REAUTH_REQUIRED',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCodexExecutable({
|
||||||
|
environment = process.env,
|
||||||
|
home = os.homedir(),
|
||||||
|
access = accessSync,
|
||||||
|
} = {}) {
|
||||||
|
const explicit = String(environment.CODEX_BIN || '').trim();
|
||||||
|
if (explicit) return explicit;
|
||||||
|
|
||||||
|
for (const candidate of [
|
||||||
|
path.join(home, '.local', 'bin', 'codex'),
|
||||||
|
path.join(
|
||||||
|
home,
|
||||||
|
'.codex',
|
||||||
|
'packages',
|
||||||
|
'standalone',
|
||||||
|
'current',
|
||||||
|
'bin',
|
||||||
|
'codex',
|
||||||
|
),
|
||||||
|
]) {
|
||||||
|
try {
|
||||||
|
access(candidate, fsConstants.X_OK);
|
||||||
|
return candidate;
|
||||||
|
} catch {
|
||||||
|
// Fall through to the next known location, then PATH.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'codex';
|
||||||
|
}
|
||||||
|
|
||||||
|
// What `codex login` needs to find its home, open a browser and reach OpenAI.
|
||||||
|
// The server's own environment also holds every provider secret, so the
|
||||||
|
// child receives only these names.
|
||||||
|
const CODEX_LOGIN_ENV =
|
||||||
|
/^(?:PATH|PATHEXT|HOME|USER|USERNAME|LOGNAME|SHELL|LANG|LANGUAGE|LC_[A-Z_]+|TERM|TZ|TMPDIR|TEMP|TMP|XDG_[A-Z_]+|DISPLAY|WAYLAND_DISPLAY|DBUS_SESSION_BUS_ADDRESS|BROWSER|WSL_[A-Z_]+|WSLENV|(?:HTTPS?|ALL|NO)_PROXY|SSL_CERT_(?:FILE|DIR)|NODE_EXTRA_CA_CERTS|SYSTEMROOT|WINDIR|COMSPEC|USERPROFILE|APPDATA|LOCALAPPDATA|PROGRAMDATA|HOMEDRIVE|HOMEPATH|CODEX_[A-Z_]+)$/i;
|
||||||
|
|
||||||
|
/** The environment `codex login` runs with: the allowlist, then `CODEX_HOME`. */
|
||||||
|
export function codexLoginEnvironment(environment, codexHome) {
|
||||||
|
const env = {};
|
||||||
|
for (const [name, value] of Object.entries(environment || {}))
|
||||||
|
if (CODEX_LOGIN_ENV.test(name) && value !== undefined) env[name] = value;
|
||||||
|
env.CODEX_HOME = codexHome;
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startCodexChatGptLogin({
|
||||||
|
spawnImpl = spawn,
|
||||||
|
environment = process.env,
|
||||||
|
home = os.homedir(),
|
||||||
|
executable = resolveCodexExecutable({ environment, home }),
|
||||||
|
} = {}) {
|
||||||
|
// Codex owns its credential storage. An explicit read path must agree with
|
||||||
|
// the CLI's write path; never silently sign in to another credential store.
|
||||||
|
const configuredHome = String(environment.CODEX_HOME || '').trim();
|
||||||
|
const loginHome = configuredHome
|
||||||
|
? path.resolve(configuredHome)
|
||||||
|
: path.join(home, '.codex');
|
||||||
|
if (
|
||||||
|
codexAuthPath({ environment, home }) !== path.join(loginHome, 'auth.json')
|
||||||
|
) {
|
||||||
|
return Promise.reject(
|
||||||
|
codexOAuthError(
|
||||||
|
'CODEX_AUTH_JSON does not match the Codex login storage path. Set CODEX_HOME to the matching directory or sign in to Codex separately.',
|
||||||
|
'CODEX_OAUTH_STORAGE_MISMATCH',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let child;
|
||||||
|
let spawned = false;
|
||||||
|
let finish;
|
||||||
|
const completion = new Promise((done) => {
|
||||||
|
finish = done;
|
||||||
|
});
|
||||||
|
const fail = () => {
|
||||||
|
reject(
|
||||||
|
codexOAuthError(
|
||||||
|
'Could not start ChatGPT sign-in. Check that Codex is installed, or set CODEX_BIN to its executable.',
|
||||||
|
'CODEX_OAUTH_LOGIN_FAILED',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
finish({ exitCode: null });
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
child = spawnImpl(executable, ['login'], {
|
||||||
|
cwd: home,
|
||||||
|
detached: true,
|
||||||
|
stdio: 'ignore',
|
||||||
|
env: codexLoginEnvironment(environment, loginHome),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
fail();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
child.once('error', () => {
|
||||||
|
if (!spawned) fail();
|
||||||
|
else finish({ exitCode: null });
|
||||||
|
});
|
||||||
|
child.once('exit', (exitCode) => finish({ exitCode }));
|
||||||
|
child.once('spawn', () => {
|
||||||
|
spawned = true;
|
||||||
|
child.unref?.();
|
||||||
|
resolve({
|
||||||
|
completion,
|
||||||
|
cancel() {
|
||||||
|
try {
|
||||||
|
child.kill?.('SIGTERM');
|
||||||
|
} catch {
|
||||||
|
/* Already stopped. */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One local login owner shared by the launch and status endpoints. */
|
||||||
|
export function createCodexOAuthLogin({
|
||||||
|
resolveOAuthAccessToken = () => readCodexOAuthAccessToken(),
|
||||||
|
startOAuthLogin = () => startCodexChatGptLogin(),
|
||||||
|
loginTimeoutMs = 120_000,
|
||||||
|
setTimer = setTimeout,
|
||||||
|
clearTimer = clearTimeout,
|
||||||
|
} = {}) {
|
||||||
|
let active = null;
|
||||||
|
let failure = null;
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
|
function credentials() {
|
||||||
|
try {
|
||||||
|
if (resolveOAuthAccessToken()) return { available: true };
|
||||||
|
} catch (error) {
|
||||||
|
const code = error?.code || 'CODEX_OAUTH_UNAVAILABLE';
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
error: error?.message || 'ChatGPT sign-in is unavailable',
|
||||||
|
code,
|
||||||
|
reauthRequired: code === 'CODEX_OAUTH_REAUTH_REQUIRED',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
error: 'ChatGPT sign-in is unavailable',
|
||||||
|
code: 'CODEX_OAUTH_UNAVAILABLE',
|
||||||
|
reauthRequired: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function status() {
|
||||||
|
const current = credentials();
|
||||||
|
if (current.available) return current;
|
||||||
|
if (failure) return { available: false, ...failure, loginFailed: true };
|
||||||
|
return active ? { ...current, pending: true } : current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish(attempt, error) {
|
||||||
|
if (active !== attempt) return;
|
||||||
|
clearTimer(attempt.timer);
|
||||||
|
active = null;
|
||||||
|
failure = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
if (disposed)
|
||||||
|
throw codexOAuthError(
|
||||||
|
'ChatGPT sign-in is unavailable because the server has stopped.',
|
||||||
|
'CODEX_OAUTH_LOGIN_FAILED',
|
||||||
|
);
|
||||||
|
if (credentials().available)
|
||||||
|
return { available: true, started: false, pending: false };
|
||||||
|
if (active) return { available: false, started: false, pending: true };
|
||||||
|
|
||||||
|
// Reserve the attempt before awaiting spawn so concurrent POSTs share it.
|
||||||
|
const attempt = { process: null, timer: null };
|
||||||
|
active = attempt;
|
||||||
|
failure = null;
|
||||||
|
attempt.timer = setTimer(() => {
|
||||||
|
finish(attempt, {
|
||||||
|
code: 'CODEX_OAUTH_LOGIN_TIMEOUT',
|
||||||
|
error:
|
||||||
|
'ChatGPT sign-in timed out. Click USE CHATGPT OAUTH to try again.',
|
||||||
|
});
|
||||||
|
attempt.process?.cancel();
|
||||||
|
}, loginTimeoutMs);
|
||||||
|
attempt.timer?.unref?.();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const login = await startOAuthLogin();
|
||||||
|
if (!login?.completion || typeof login.cancel !== 'function')
|
||||||
|
throw new Error('Invalid login process');
|
||||||
|
attempt.process = login;
|
||||||
|
if (active !== attempt) {
|
||||||
|
login.cancel();
|
||||||
|
return { ...status(), started: false, pending: false };
|
||||||
|
}
|
||||||
|
Promise.resolve(login.completion).then(
|
||||||
|
(result) => {
|
||||||
|
if (active !== attempt) return;
|
||||||
|
if (result?.exitCode !== 0) {
|
||||||
|
finish(attempt, {
|
||||||
|
code: 'CODEX_OAUTH_LOGIN_FAILED',
|
||||||
|
error:
|
||||||
|
'ChatGPT sign-in did not complete. Try again in Provider Settings, or run codex login separately.',
|
||||||
|
});
|
||||||
|
} else if (!credentials().available) {
|
||||||
|
finish(attempt, {
|
||||||
|
code: 'CODEX_OAUTH_STORAGE_UNAVAILABLE',
|
||||||
|
error:
|
||||||
|
'Codex sign-in finished, but its configured auth.json has no usable ChatGPT token. Check CODEX_HOME and Codex credential storage. GEV can read file storage only.',
|
||||||
|
});
|
||||||
|
} else finish(attempt, null);
|
||||||
|
},
|
||||||
|
() =>
|
||||||
|
finish(attempt, {
|
||||||
|
code: 'CODEX_OAUTH_LOGIN_FAILED',
|
||||||
|
error:
|
||||||
|
'ChatGPT sign-in did not complete. Try again in Provider Settings.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return { available: false, started: true, pending: true };
|
||||||
|
} catch (error) {
|
||||||
|
const code =
|
||||||
|
error?.code === 'CODEX_OAUTH_STORAGE_MISMATCH'
|
||||||
|
? error.code
|
||||||
|
: 'CODEX_OAUTH_LOGIN_FAILED';
|
||||||
|
finish(attempt, {
|
||||||
|
code,
|
||||||
|
error:
|
||||||
|
code === 'CODEX_OAUTH_STORAGE_MISMATCH'
|
||||||
|
? error.message
|
||||||
|
: 'Could not start ChatGPT sign-in. Check that Codex is installed, or set CODEX_BIN to its executable.',
|
||||||
|
});
|
||||||
|
return { ...status(), started: false, pending: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
start,
|
||||||
|
dispose() {
|
||||||
|
disposed = true;
|
||||||
|
if (!active) return;
|
||||||
|
const attempt = active;
|
||||||
|
finish(attempt, null);
|
||||||
|
attempt.process?.cancel();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -14,12 +14,126 @@ import {
|
|||||||
} from './constants.js';
|
} from './constants.js';
|
||||||
import { realtimeInstructions } from './instructions.js';
|
import { realtimeInstructions } from './instructions.js';
|
||||||
import { GEV_REALTIME_TOOLS } from './tools.js';
|
import { GEV_REALTIME_TOOLS } from './tools.js';
|
||||||
|
import {
|
||||||
|
preferCodexOAuth,
|
||||||
|
readCodexOAuthAccessToken,
|
||||||
|
createCodexOAuthLogin,
|
||||||
|
} from './codex-auth.js';
|
||||||
|
|
||||||
|
const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
||||||
|
|
||||||
|
function isLoopbackRequest(req) {
|
||||||
|
return LOOPBACK_ADDRESSES.has(String(req.socket?.remoteAddress || ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameOriginRequest(req) {
|
||||||
|
const origin = String(req.headers?.origin || '').trim();
|
||||||
|
if (!origin) return true;
|
||||||
|
const host = String(req.headers?.host || '').trim();
|
||||||
|
if (!host) return false;
|
||||||
|
const protocol = req.socket?.encrypted ? 'https:' : 'http:';
|
||||||
|
try {
|
||||||
|
return new URL(origin).origin === new URL(`${protocol}//${host}`).origin;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestQuery(req) {
|
||||||
|
try {
|
||||||
|
return new URL(req.url || '', 'http://localhost').searchParams;
|
||||||
|
} catch {
|
||||||
|
return new URLSearchParams();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRealtimeAuthMode(query) {
|
||||||
|
const requested = String(query.get('auth') || 'api-key').toLowerCase();
|
||||||
|
if (requested === 'api-key' || requested === 'oauth') return requested;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRealtimeOAuthStatusHandler({
|
||||||
|
oauthLogin = createCodexOAuthLogin(),
|
||||||
|
} = {}) {
|
||||||
|
return (req, res) => {
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
if (req.method !== 'GET') {
|
||||||
|
res.statusCode = 405;
|
||||||
|
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isLoopbackRequest(req)) {
|
||||||
|
res.statusCode = 403;
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
available: false,
|
||||||
|
error: 'ChatGPT OAuth is available only from this machine',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.end(JSON.stringify(oauthLogin.status()));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRealtimeOAuthLoginHandler({
|
||||||
|
oauthLogin = createCodexOAuthLogin(),
|
||||||
|
} = {}) {
|
||||||
|
return async (req, res) => {
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
res.statusCode = 405;
|
||||||
|
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isLoopbackRequest(req)) {
|
||||||
|
res.statusCode = 403;
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
available: false,
|
||||||
|
error: 'ChatGPT OAuth sign-in is available only from this machine',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isSameOriginRequest(req)) {
|
||||||
|
res.statusCode = 403;
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
available: false,
|
||||||
|
error: 'Cross-origin ChatGPT OAuth sign-in is not allowed',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await oauthLogin.start();
|
||||||
|
res.statusCode = result.available ? 200 : result.loginFailed ? 503 : 202;
|
||||||
|
res.end(JSON.stringify(result));
|
||||||
|
} catch {
|
||||||
|
res.statusCode = 503;
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
available: false,
|
||||||
|
error: 'Could not start ChatGPT sign-in',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function createRealtimeTokenHandler({
|
function createRealtimeTokenHandler({
|
||||||
annotationGuidance,
|
annotationGuidance,
|
||||||
endpoint = 'https://api.openai.com/v1/realtime/client_secrets',
|
endpoint = 'https://api.openai.com/v1/realtime/client_secrets',
|
||||||
fetchImpl = (...args) => fetch(...args),
|
fetchImpl = (...args) => fetch(...args),
|
||||||
resolveApiKey = () => process.env.OPENAI_API_KEY,
|
resolveApiKey = () => process.env.OPENAI_API_KEY,
|
||||||
|
resolveOAuthAccessToken = () => readCodexOAuthAccessToken(),
|
||||||
|
environment = process.env,
|
||||||
models = {},
|
models = {},
|
||||||
tools = GEV_REALTIME_TOOLS,
|
tools = GEV_REALTIME_TOOLS,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
@@ -32,15 +146,84 @@ function createRealtimeTokenHandler({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const query = requestQuery(req);
|
||||||
|
const authMode = resolveRealtimeAuthMode(query);
|
||||||
|
if (!authMode) {
|
||||||
|
res.statusCode = 400;
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
res.end(JSON.stringify({ error: 'Unsupported cloud voice auth mode' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let oauthPinned;
|
||||||
|
try {
|
||||||
|
oauthPinned = preferCodexOAuth(environment);
|
||||||
|
} catch (error) {
|
||||||
|
res.statusCode = 503;
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
res.end(JSON.stringify({ error: error.message }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (oauthPinned && authMode !== 'oauth') {
|
||||||
|
res.statusCode = 503;
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
error:
|
||||||
|
'ChatGPT OAuth is required by GEV_PREFER_CODEX_OAUTH. Select USE CHATGPT OAUTH in Provider Settings.',
|
||||||
|
code: 'CODEX_OAUTH_REQUIRED',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). On by default; 0 disables.
|
// Per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). On by default; 0 disables.
|
||||||
if (!enforceRateLimit(openAiRateLimiter(), req, res)) return;
|
if (!enforceRateLimit(openAiRateLimiter(), req, res)) return;
|
||||||
|
|
||||||
const apiKey = resolveApiKey();
|
let bearer;
|
||||||
if (!apiKey) {
|
if (authMode === 'oauth') {
|
||||||
res.statusCode = 503;
|
if (!isLoopbackRequest(req)) {
|
||||||
res.setHeader('Content-Type', 'application/json');
|
res.statusCode = 403;
|
||||||
res.end(JSON.stringify({ error: 'OPENAI_API_KEY is not set' }));
|
res.setHeader('Content-Type', 'application/json');
|
||||||
return;
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'ChatGPT OAuth voice is available only from this machine',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
bearer = resolveOAuthAccessToken();
|
||||||
|
} catch (error) {
|
||||||
|
res.statusCode = 503;
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
error: error?.message || 'ChatGPT sign-in is unavailable',
|
||||||
|
code: error?.code || 'CODEX_OAUTH_UNAVAILABLE',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!bearer) {
|
||||||
|
res.statusCode = 503;
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'ChatGPT sign-in is unavailable',
|
||||||
|
code: 'CODEX_OAUTH_UNAVAILABLE',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bearer = resolveApiKey();
|
||||||
|
if (!bearer) {
|
||||||
|
res.statusCode = 503;
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
res.end(JSON.stringify({ error: 'OPENAI_API_KEY is not set' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Voice model tier, requested by the client as ?tier=standard|mini.
|
// Voice model tier, requested by the client as ?tier=standard|mini.
|
||||||
@@ -49,15 +232,7 @@ function createRealtimeTokenHandler({
|
|||||||
// bad querystring degrades to a normal session rather than a dead mic.
|
// bad querystring degrades to a normal session rather than a dead mic.
|
||||||
// The env overrides stay authoritative per tier (see .env.example) —
|
// The env overrides stay authoritative per tier (see .env.example) —
|
||||||
// a wrong upstream model id is then a config fix, not a code change.
|
// a wrong upstream model id is then a config fix, not a code change.
|
||||||
const requestedTier = (() => {
|
const requestedTier = query.get('tier');
|
||||||
try {
|
|
||||||
return new URL(req.url || '', 'http://localhost').searchParams.get(
|
|
||||||
'tier',
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
const tier = resolveVoiceModel(requestedTier).tier;
|
const tier = resolveVoiceModel(requestedTier).tier;
|
||||||
const model =
|
const model =
|
||||||
tier === 'mini'
|
tier === 'mini'
|
||||||
@@ -135,7 +310,7 @@ function createRealtimeTokenHandler({
|
|||||||
redirect: 'error',
|
redirect: 'error',
|
||||||
signal: AbortSignal.timeout(30_000),
|
signal: AbortSignal.timeout(30_000),
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${apiKey}`,
|
Authorization: `Bearer ${bearer}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'OpenAI-Safety-Identifier': 'gev-local-dev',
|
'OpenAI-Safety-Identifier': 'gev-local-dev',
|
||||||
},
|
},
|
||||||
@@ -149,6 +324,10 @@ function createRealtimeTokenHandler({
|
|||||||
// case where a bogus ?tier= was silently downgraded to standard.
|
// case where a bogus ?tier= was silently downgraded to standard.
|
||||||
res.setHeader('X-GEV-Voice-Tier', tier);
|
res.setHeader('X-GEV-Voice-Tier', tier);
|
||||||
res.setHeader('X-GEV-Voice-Model', model);
|
res.setHeader('X-GEV-Voice-Model', model);
|
||||||
|
res.setHeader(
|
||||||
|
'X-GEV-Voice-Auth',
|
||||||
|
authMode === 'oauth' ? 'codex-oauth' : 'env',
|
||||||
|
);
|
||||||
// Captions are billed separately; the client meters them with this id.
|
// Captions are billed separately; the client meters them with this id.
|
||||||
res.setHeader(
|
res.setHeader(
|
||||||
'X-GEV-Voice-Transcribe-Model',
|
'X-GEV-Voice-Transcribe-Model',
|
||||||
@@ -183,4 +362,8 @@ function createRealtimeTokenHandler({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export { createRealtimeTokenHandler };
|
export {
|
||||||
|
createRealtimeOAuthLoginHandler,
|
||||||
|
createRealtimeOAuthStatusHandler,
|
||||||
|
createRealtimeTokenHandler,
|
||||||
|
};
|
||||||
@@ -65,13 +65,28 @@ test('data setup seals the caller catalog before controls can restore and drains
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
catalog: createLayerCatalog(layers, metadata(layers)),
|
catalog: {
|
||||||
|
...createLayerCatalog(layers, metadata(layers)),
|
||||||
|
getSourceAvailability: (id) =>
|
||||||
|
id === 'one'
|
||||||
|
? {
|
||||||
|
available: false,
|
||||||
|
reason: 'One: data source not configured',
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
defer: (release) => releases.push(release),
|
defer: (release) => releases.push(release),
|
||||||
onData: (manager) => {
|
onData: (manager) => {
|
||||||
observed = manager;
|
observed = manager;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert.equal(observed, dataManager);
|
assert.equal(observed, dataManager);
|
||||||
|
assert.equal(await dataManager.setEnabled('one', true), false);
|
||||||
|
assert.equal(dataManager.layers.get('one').initialized, false);
|
||||||
|
assert.equal(
|
||||||
|
dataManager.getAll().find(({ id }) => id === 'one').stats.error,
|
||||||
|
'One: data source not configured',
|
||||||
|
);
|
||||||
assert.deepEqual(attached, [['one', 'two']]);
|
assert.deepEqual(attached, [['one', 'two']]);
|
||||||
assert.deepEqual([...dataManager.layers.keys()], ['one', 'two']);
|
assert.deepEqual([...dataManager.layers.keys()], ['one', 'two']);
|
||||||
assert.throws(() => dataManager.register({ id: 'late' }), /finalized/);
|
assert.throws(() => dataManager.register({ id: 'late' }), /finalized/);
|
||||||
|
|||||||
+13
-37
@@ -1,3 +1,4 @@
|
|||||||
|
import { resolveCatalogSources } from './sourceComposition.js';
|
||||||
import { createWeatherClock } from '../layers/weather/clock.js';
|
import { createWeatherClock } from '../layers/weather/clock.js';
|
||||||
import { createWeatherLayer } from '../layers/weather/index.js';
|
import { createWeatherLayer } from '../layers/weather/index.js';
|
||||||
import { createCyclonesLayer } from '../layers/cyclones/index.js';
|
import { createCyclonesLayer } from '../layers/cyclones/index.js';
|
||||||
@@ -31,34 +32,6 @@ import { localGeoJsonServices } from './localGeojsonServices.js';
|
|||||||
import { createBhoteKoshiEventLayer } from '../data/bhoteKoshiEvent.js';
|
import { createBhoteKoshiEventLayer } from '../data/bhoteKoshiEvent.js';
|
||||||
import { createBhoteKoshiLocatorLayer } from '../data/bhoteKoshiLocator.js';
|
import { createBhoteKoshiLocatorLayer } from '../data/bhoteKoshiLocator.js';
|
||||||
|
|
||||||
const SOURCE_METHODS = Object.freeze({
|
|
||||||
flights: ['getSnapshot'],
|
|
||||||
military: ['getSnapshot'],
|
|
||||||
vessels: ['getSnapshot'],
|
|
||||||
cctv: ['getCatalog', 'getHealth', 'getFrameUrl', 'getMediaUrl'],
|
|
||||||
radio: ['getDirectory', 'recordClick'],
|
|
||||||
traffic: [
|
|
||||||
'requestRoads',
|
|
||||||
'getStatus',
|
|
||||||
'fetchFlowForBounds',
|
|
||||||
'getFlowSessionStats',
|
|
||||||
'resetFlowTileCache',
|
|
||||||
],
|
|
||||||
bikeshare: ['getStations'],
|
|
||||||
installations: ['getMappedSites', 'searchNearby'],
|
|
||||||
satellites: ['readGroup'],
|
|
||||||
launches: ['getLaunches', 'getActiveTle'],
|
|
||||||
alpr: ['fetch'],
|
|
||||||
firms: ['getSnapshot'],
|
|
||||||
wind: ['getSnapshot'],
|
|
||||||
weather: ['getSnapshot'],
|
|
||||||
cyclones: ['getSnapshot'],
|
|
||||||
earthquakes: ['getSnapshot'],
|
|
||||||
'fire-perimeters': ['getSnapshot'],
|
|
||||||
cables: ['fetch'],
|
|
||||||
mapillary: ['getStatus', 'getTile', 'getSequenceImages'],
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hardware-local layers are registered like any other but never enter share
|
* Hardware-local layers are registered like any other but never enter share
|
||||||
* links or stored layer state: another browser cannot have this receiver.
|
* links or stored layer state: another browser cannot have this receiver.
|
||||||
@@ -85,6 +58,7 @@ export function createApplicationCatalog({
|
|||||||
vesselOptions,
|
vesselOptions,
|
||||||
resolveAsset,
|
resolveAsset,
|
||||||
nepalBoundaryResolver,
|
nepalBoundaryResolver,
|
||||||
|
streetLevelProviders,
|
||||||
}) {
|
}) {
|
||||||
if (!signal?.addEventListener)
|
if (!signal?.addEventListener)
|
||||||
throw new TypeError('An application lifetime signal is required');
|
throw new TypeError('An application lifetime signal is required');
|
||||||
@@ -92,12 +66,8 @@ export function createApplicationCatalog({
|
|||||||
if (!surface?.groundFloor || !surface?.terrain)
|
if (!surface?.groundFloor || !surface?.terrain)
|
||||||
throw new TypeError('Application surface services are required');
|
throw new TypeError('Application surface services are required');
|
||||||
|
|
||||||
for (const [name, methods] of Object.entries(SOURCE_METHODS)) {
|
const sourceComposition = resolveCatalogSources(sources);
|
||||||
if (
|
sources = sourceComposition.sources;
|
||||||
methods.some((method) => typeof sources?.[name]?.[method] !== 'function')
|
|
||||||
)
|
|
||||||
throw new TypeError(`Invalid catalog source: ${name}`);
|
|
||||||
}
|
|
||||||
const militaryRegistry = createMilitaryRegistry();
|
const militaryRegistry = createMilitaryRegistry();
|
||||||
const weatherClock = createWeatherClock();
|
const weatherClock = createWeatherClock();
|
||||||
const dispose = () => {
|
const dispose = () => {
|
||||||
@@ -107,7 +77,8 @@ export function createApplicationCatalog({
|
|||||||
};
|
};
|
||||||
signal.addEventListener('abort', dispose, { once: true });
|
signal.addEventListener('abort', dispose, { once: true });
|
||||||
try {
|
try {
|
||||||
militaryRegistry.configureSource(sources.military, { signal });
|
if (sourceComposition.isConfigured('military'))
|
||||||
|
militaryRegistry.configureSource(sources.military, { signal });
|
||||||
const flights = createApplicationFlights({
|
const flights = createApplicationFlights({
|
||||||
surface,
|
surface,
|
||||||
source: sources.flights,
|
source: sources.flights,
|
||||||
@@ -151,7 +122,9 @@ export function createApplicationCatalog({
|
|||||||
}),
|
}),
|
||||||
createApplicationAlpr({ surface, source: sources.alpr }),
|
createApplicationAlpr({ surface, source: sources.alpr }),
|
||||||
createApplicationStreetLevel({
|
createApplicationStreetLevel({
|
||||||
|
surface,
|
||||||
sources: { mapillary: sources.mapillary },
|
sources: { mapillary: sources.mapillary },
|
||||||
|
providers: streetLevelProviders,
|
||||||
}),
|
}),
|
||||||
satellites,
|
satellites,
|
||||||
createApplicationLaunches({ source: sources.launches, satellites }),
|
createApplicationLaunches({ source: sources.launches, satellites }),
|
||||||
@@ -160,8 +133,8 @@ export function createApplicationCatalog({
|
|||||||
createApplicationRadio({ surface, source: sources.radio }),
|
createApplicationRadio({ surface, source: sources.radio }),
|
||||||
createApplicationTransit({ surface, source: sources.transit }),
|
createApplicationTransit({ surface, source: sources.transit }),
|
||||||
createApplicationBikeshare({ source: sources.bikeshare }),
|
createApplicationBikeshare({ source: sources.bikeshare }),
|
||||||
createApplicationDirections(),
|
createApplicationDirections({ source: sources.directions }),
|
||||||
createApplicationRecentImagery(),
|
createApplicationRecentImagery({ source: sources['recent-imagery'] }),
|
||||||
vessels,
|
vessels,
|
||||||
installations,
|
installations,
|
||||||
createApplicationAwareness({
|
createApplicationAwareness({
|
||||||
@@ -202,6 +175,9 @@ export function createApplicationCatalog({
|
|||||||
);
|
);
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
...catalog,
|
...catalog,
|
||||||
|
getSourceAvailability: sourceComposition.createAvailabilityLookup(
|
||||||
|
catalog.layers,
|
||||||
|
),
|
||||||
militaryRegistry,
|
militaryRegistry,
|
||||||
surface,
|
surface,
|
||||||
weatherClock,
|
weatherClock,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { createApplicationCatalog } from './constructCatalog.js';
|
|||||||
import { createStandaloneLayerSources } from '../standalone/layerSources.js';
|
import { createStandaloneLayerSources } from '../standalone/layerSources.js';
|
||||||
import { catalogControlServices } from './catalog.js';
|
import { catalogControlServices } from './catalog.js';
|
||||||
import { LayerLifecycle } from '../data/lifecycle.js';
|
import { LayerLifecycle } from '../data/lifecycle.js';
|
||||||
|
import { fakeStreetLevelProvider } from '../testSupport/streetLevelFakes.mjs';
|
||||||
|
|
||||||
function fixtureSources(ids, calls) {
|
function fixtureSources(ids, calls) {
|
||||||
const sources = createStandaloneLayerSources();
|
const sources = createStandaloneLayerSources();
|
||||||
@@ -116,7 +117,7 @@ test('invalid or already cancelled construction fails before classification can
|
|||||||
assert.throws(
|
assert.throws(
|
||||||
() =>
|
() =>
|
||||||
createApplicationCatalog({
|
createApplicationCatalog({
|
||||||
sources: {},
|
sources: { flights: {} },
|
||||||
signal: lifetime.signal,
|
signal: lifetime.signal,
|
||||||
surface: fixtureSurface(lifetime.signal),
|
surface: fixtureSurface(lifetime.signal),
|
||||||
}),
|
}),
|
||||||
@@ -140,3 +141,53 @@ function fixtureSurface(signal) {
|
|||||||
eventTarget: null,
|
eventTarget: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('Mapillary can be omitted or replaced without changing catalog membership', (t) => {
|
||||||
|
const lifetime = new AbortController();
|
||||||
|
t.after(() => lifetime.abort());
|
||||||
|
const sources = fixtureSources([], []);
|
||||||
|
delete sources.mapillary;
|
||||||
|
const options = {
|
||||||
|
sources,
|
||||||
|
signal: lifetime.signal,
|
||||||
|
surface: fixtureSurface(lifetime.signal),
|
||||||
|
};
|
||||||
|
const catalog = createApplicationCatalog(options);
|
||||||
|
assert.equal(catalog.layers.length, 30);
|
||||||
|
assert.deepEqual(catalog.get('street-level').providerIds, []);
|
||||||
|
assert.equal(
|
||||||
|
catalogControlServices(catalog).streetLevelLayer,
|
||||||
|
catalog.get('street-level'),
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
catalog.get('street-level').getUIState().coverage.hint,
|
||||||
|
/No street-level imagery providers/,
|
||||||
|
);
|
||||||
|
const provider = fakeStreetLevelProvider({
|
||||||
|
id: 'example',
|
||||||
|
pickPrefix: 'example:',
|
||||||
|
});
|
||||||
|
const replacement = createApplicationCatalog({
|
||||||
|
...options,
|
||||||
|
streetLevelProviders: [provider],
|
||||||
|
});
|
||||||
|
assert.deepEqual(replacement.get('street-level').providerIds, ['example']);
|
||||||
|
assert.deepEqual(replacement.metadata, catalog.metadata);
|
||||||
|
assert.equal(provider.calls.activate, 0, 'construction must not acquire');
|
||||||
|
assert.deepEqual(
|
||||||
|
createApplicationCatalog({
|
||||||
|
...options,
|
||||||
|
sources: fixtureSources([], []),
|
||||||
|
streetLevelProviders: [],
|
||||||
|
}).get('street-level').providerIds,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createApplicationCatalog({
|
||||||
|
...options,
|
||||||
|
sources: { ...sources, mapillary: {} },
|
||||||
|
}),
|
||||||
|
/Mapillary source/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ export function createApplicationData({
|
|||||||
// Initialize data layer manager
|
// Initialize data layer manager
|
||||||
const dataManager = new LayerLifecycle(viewer, {
|
const dataManager = new LayerLifecycle(viewer, {
|
||||||
allowQaRegistration,
|
allowQaRegistration,
|
||||||
|
getSourceAvailability: catalog?.getSourceAvailability,
|
||||||
});
|
});
|
||||||
defer(async () => {
|
defer(async () => {
|
||||||
await dataManager.destroyAll();
|
await dataManager.destroyAll();
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import { createApplicationDirectionsLayer } from '../../data/directions.js';
|
import { createApplicationDirectionsLayer } from '../../data/directions.js';
|
||||||
|
|
||||||
/**
|
/** Construct Directions with application scene owners and an explicit route source. */
|
||||||
* Construct one Directions layer using the application scene owners.
|
export function createApplicationDirections({ source }) {
|
||||||
*
|
return createApplicationDirectionsLayer({ source });
|
||||||
* It takes no data source: a route is fetched from `/api/route` when the
|
|
||||||
* operator places both endpoints, so there is nothing to supply up front.
|
|
||||||
* @returns {object} A fresh layer instance for this catalog.
|
|
||||||
*/
|
|
||||||
export function createApplicationDirections() {
|
|
||||||
return createApplicationDirectionsLayer();
|
|
||||||
}
|
}
|
||||||
@@ -3,17 +3,17 @@ import { createRecentImageryRenderer } from '../../layers/recentImagery/renderin
|
|||||||
import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js';
|
import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js';
|
||||||
import { governorRequestRender } from '../../renderGovernor.js';
|
import { governorRequestRender } from '../../renderGovernor.js';
|
||||||
|
|
||||||
/**
|
/** Construct imagery presentation over one explicitly supplied acquisition source. */
|
||||||
* Construct the Recent Imagery layer with the real GIBS renderer and the
|
export function createApplicationRecentImagery({ source }) {
|
||||||
* browser thumbnail loader. It takes no data source: the catalog is NASA
|
|
||||||
* CMR and the tiles are GIBS, both browser-direct and keyless.
|
|
||||||
* @returns {object} A fresh layer instance for this catalog.
|
|
||||||
*/
|
|
||||||
export function createApplicationRecentImagery() {
|
|
||||||
return createRecentImageryLayer({
|
return createRecentImageryLayer({
|
||||||
|
catalog: { searchHls: (...args) => source.searchHls(...args) },
|
||||||
renderer: createRecentImageryRenderer({
|
renderer: createRecentImageryRenderer({
|
||||||
requestRender: governorRequestRender,
|
requestRender: governorRequestRender,
|
||||||
|
tileTemplate: (...args) => source.getTileTemplate(...args),
|
||||||
|
credit: source.credit || source.label || 'Imagery',
|
||||||
|
}),
|
||||||
|
thumbnails: createThumbnailLoader({
|
||||||
|
requestThumbnail: (...args) => source.getThumbnail(...args),
|
||||||
}),
|
}),
|
||||||
thumbnails: createThumbnailLoader(),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,23 @@
|
|||||||
import { createStreetLevelLayer } from '../../layers/streetLevel/index.js';
|
import { createStreetLevelLayer } from '../../layers/streetLevel/index.js';
|
||||||
|
import { createMapillaryProvider } from '../../layers/streetLevel/mapillaryProvider.js';
|
||||||
import * as sprites from '../../data/spriteOrder.js';
|
import * as sprites from '../../data/spriteOrder.js';
|
||||||
import * as picking from '../../data/pickRegistry.js';
|
import * as picking from '../../data/pickRegistry.js';
|
||||||
import * as input from '../../data/inputOwnership.js';
|
import * as input from '../../data/inputOwnership.js';
|
||||||
import * as render from '../../renderGovernor.js';
|
import * as render from '../../renderGovernor.js';
|
||||||
|
|
||||||
/** The Street Level layer over the application's Mapillary source. */
|
/**
|
||||||
export function createApplicationStreetLevel({ sources }) {
|
* Inject providers, or use Mapillary only when its source is supplied.
|
||||||
|
* An explicit provider list replaces the default; an unused sources.mapillary
|
||||||
|
* is neither constructed nor validated, including when the list is empty.
|
||||||
|
*/
|
||||||
|
export function createApplicationStreetLevel({
|
||||||
|
sources = {},
|
||||||
|
providers = sources.mapillary == null
|
||||||
|
? []
|
||||||
|
: [createMapillaryProvider({ source: sources.mapillary })],
|
||||||
|
}) {
|
||||||
return createStreetLevelLayer({
|
return createStreetLevelLayer({
|
||||||
source: sources.mapillary,
|
providers,
|
||||||
services: { sprites, picking, input, render },
|
services: { sprites, picking, input, render },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createApplicationStreetLevel } from './streetLevel.js';
|
||||||
|
import { fakeStreetLevelProvider } from '../../testSupport/streetLevelFakes.mjs';
|
||||||
|
|
||||||
|
test('explicit providers replace the default source and an empty list removes it', () => {
|
||||||
|
const sources = { mapillary: {} };
|
||||||
|
const provider = fakeStreetLevelProvider({
|
||||||
|
id: 'example',
|
||||||
|
pickPrefix: 'example:',
|
||||||
|
});
|
||||||
|
const layer = createApplicationStreetLevel({
|
||||||
|
sources,
|
||||||
|
providers: [provider],
|
||||||
|
});
|
||||||
|
assert.deepEqual(layer.providerIds, ['example']);
|
||||||
|
assert.deepEqual(
|
||||||
|
createApplicationStreetLevel({ sources, providers: [] }).providerIds,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => createApplicationStreetLevel({ sources }),
|
||||||
|
/Mapillary source/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => createApplicationStreetLevel({ providers: null }),
|
||||||
|
/array/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
const contract = (layers, methods) =>
|
||||||
|
Object.freeze({
|
||||||
|
layers: Object.freeze(layers),
|
||||||
|
methods: Object.freeze(methods),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Acquisition contracts and their consumers; records remain family-specific. */
|
||||||
|
export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
|
||||||
|
directions: contract(['directions'], ['getRoute']),
|
||||||
|
'recent-imagery': contract(
|
||||||
|
['recent-imagery'],
|
||||||
|
['searchHls', 'getThumbnail', 'getTileTemplate'],
|
||||||
|
),
|
||||||
|
flights: contract(['flights'], ['getSnapshot']),
|
||||||
|
military: contract(['military'], ['getSnapshot']),
|
||||||
|
vessels: contract(['ais-live-vessels'], ['getSnapshot']),
|
||||||
|
cctv: contract(
|
||||||
|
['cctv'],
|
||||||
|
['getCatalog', 'getHealth', 'getFrameUrl', 'getMediaUrl'],
|
||||||
|
),
|
||||||
|
radio: contract(['radio'], ['getDirectory', 'recordClick']),
|
||||||
|
traffic: contract(
|
||||||
|
['traffic'],
|
||||||
|
[
|
||||||
|
'requestRoads',
|
||||||
|
'getStatus',
|
||||||
|
'fetchFlowForBounds',
|
||||||
|
'getFlowSessionStats',
|
||||||
|
'resetFlowTileCache',
|
||||||
|
],
|
||||||
|
),
|
||||||
|
bikeshare: contract(['bikeshare'], ['getStations']),
|
||||||
|
installations: contract(
|
||||||
|
['military-installations'],
|
||||||
|
['getMappedSites', 'searchNearby'],
|
||||||
|
),
|
||||||
|
satellites: contract(['satellites'], ['readGroup']),
|
||||||
|
launches: contract(['rocket-launches'], ['getLaunches', 'getActiveTle']),
|
||||||
|
alpr: contract(['alpr-cameras'], ['fetch']),
|
||||||
|
firms: contract(['local-firms'], ['getSnapshot']),
|
||||||
|
wind: contract(['wind'], ['getSnapshot']),
|
||||||
|
weather: contract(
|
||||||
|
['weather-radar', 'weather-satellite', 'weather-lightning'],
|
||||||
|
['getSnapshot'],
|
||||||
|
),
|
||||||
|
cyclones: contract(['weather-cyclones'], ['getSnapshot']),
|
||||||
|
earthquakes: contract(['earthquakes'], ['getSnapshot']),
|
||||||
|
'fire-perimeters': contract(['fire-perimeters'], ['getSnapshot']),
|
||||||
|
cables: contract(['telegeography-submarine-cables'], ['fetch']),
|
||||||
|
transit: contract(['transit'], ['requestSnapshot', 'getHistory']),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** An omitted provider is unavailable, never an authoritative empty dataset. */
|
||||||
|
export class SourceUnavailableError extends Error {
|
||||||
|
constructor(name) {
|
||||||
|
super(`Data source not configured: ${name}`);
|
||||||
|
this.name = 'SourceUnavailableError';
|
||||||
|
this.code = 'unavailable';
|
||||||
|
this.unavailable = true;
|
||||||
|
this.retryable = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate supplied contracts and keep omitted sources inert during construction. */
|
||||||
|
export function resolveCatalogSources(supplied = {}) {
|
||||||
|
if (!supplied || typeof supplied !== 'object' || Array.isArray(supplied))
|
||||||
|
throw new TypeError('Catalog sources must be an object');
|
||||||
|
const sources = { ...supplied };
|
||||||
|
const missing = new Set();
|
||||||
|
for (const [name, { methods }] of Object.entries(CATALOG_SOURCE_CONTRACTS)) {
|
||||||
|
const source = sources[name];
|
||||||
|
if (source == null) {
|
||||||
|
missing.add(name);
|
||||||
|
const unavailable = () => {
|
||||||
|
throw new SourceUnavailableError(name);
|
||||||
|
};
|
||||||
|
sources[name] = Object.freeze({
|
||||||
|
label: name,
|
||||||
|
...Object.fromEntries(methods.map((method) => [method, unavailable])),
|
||||||
|
});
|
||||||
|
} else if (methods.some((method) => typeof source[method] !== 'function')) {
|
||||||
|
throw new TypeError(`Invalid catalog source: ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sources,
|
||||||
|
isConfigured: (name) => !missing.has(name),
|
||||||
|
createAvailabilityLookup(layers) {
|
||||||
|
const byId = new Map(layers.map((layer) => [layer.id, layer]));
|
||||||
|
const availability = new Map();
|
||||||
|
for (const [name, { layers: ids }] of Object.entries(
|
||||||
|
CATALOG_SOURCE_CONTRACTS,
|
||||||
|
)) {
|
||||||
|
for (const id of ids) {
|
||||||
|
const layer = byId.get(id);
|
||||||
|
if (!layer) throw new Error(`Missing source consumer: ${id}`);
|
||||||
|
availability.set(
|
||||||
|
id,
|
||||||
|
Object.freeze({
|
||||||
|
available: !missing.has(name),
|
||||||
|
source: name,
|
||||||
|
reason: missing.has(name)
|
||||||
|
? `${layer.name || id}: data source not configured`
|
||||||
|
: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (id) =>
|
||||||
|
availability.get(id) ?? byId.get(id)?.getSourceAvailability?.();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
resolveCatalogSources,
|
||||||
|
CATALOG_SOURCE_CONTRACTS,
|
||||||
|
} from './sourceComposition.js';
|
||||||
|
import { createApplicationCatalog } from './constructCatalog.js';
|
||||||
|
import { createSurfaceServices } from './surfaceServices.js';
|
||||||
|
import { createStandaloneLayerSources } from '../standalone/layerSources.js';
|
||||||
|
import { LayerLifecycle } from '../data/lifecycle.js';
|
||||||
|
|
||||||
|
function catalog(sources, t) {
|
||||||
|
const lifetime = new AbortController();
|
||||||
|
t.after(() => lifetime.abort());
|
||||||
|
return createApplicationCatalog({
|
||||||
|
sources,
|
||||||
|
signal: lifetime.signal,
|
||||||
|
surface: createSurfaceServices({
|
||||||
|
terrainSource: { getHeights: async () => [] },
|
||||||
|
signal: lifetime.signal,
|
||||||
|
eventTarget: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('every acquisition source can be omitted without losing catalog membership', (t) => {
|
||||||
|
const baseline = catalog(createStandaloneLayerSources(), t);
|
||||||
|
for (const name of Object.keys(CATALOG_SOURCE_CONTRACTS)) {
|
||||||
|
const sources = createStandaloneLayerSources({ [name]: null });
|
||||||
|
const actual = catalog(sources, t);
|
||||||
|
assert.deepEqual(actual.metadata, baseline.metadata, name);
|
||||||
|
for (const id of CATALOG_SOURCE_CONTRACTS[name].layers) {
|
||||||
|
assert.equal(actual.getSourceAvailability(id).available, false, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
|
||||||
|
const actual = catalog({}, t);
|
||||||
|
assert.equal(actual.layers.length, 30);
|
||||||
|
const manager = new LayerLifecycle(
|
||||||
|
{},
|
||||||
|
{ getSourceAvailability: actual.getSourceAvailability },
|
||||||
|
);
|
||||||
|
for (const layer of actual.layers) manager.register(layer);
|
||||||
|
for (const { layers: ids } of Object.values(CATALOG_SOURCE_CONTRACTS)) {
|
||||||
|
for (const id of ids) {
|
||||||
|
assert.equal(await manager.setEnabled(id, true), false, id);
|
||||||
|
assert.equal(manager.layers.get(id).initialized, false, id);
|
||||||
|
const row = manager.getAll().find((entry) => entry.id === id);
|
||||||
|
assert.equal(row.enabled, false);
|
||||||
|
assert.equal(row.stats.status, 'unavailable');
|
||||||
|
assert.match(row.stats.error, /data source not configured/);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('configured sources retain identity; invalid implementations never become unavailable silently', () => {
|
||||||
|
const source = { getSnapshot: async () => [] };
|
||||||
|
assert.equal(
|
||||||
|
resolveCatalogSources({ earthquakes: source }).sources.earthquakes,
|
||||||
|
source,
|
||||||
|
);
|
||||||
|
for (const name of Object.keys(CATALOG_SOURCE_CONTRACTS)) {
|
||||||
|
assert.throws(
|
||||||
|
() => resolveCatalogSources({ [name]: {} }),
|
||||||
|
/Invalid catalog source/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const missing = resolveCatalogSources({}).sources.earthquakes;
|
||||||
|
assert.throws(() => missing.getSnapshot(), { code: 'unavailable' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('standalone defaults allow exact replacements and explicit removal, and reject typos', () => {
|
||||||
|
const source = { getSnapshot: async () => [] };
|
||||||
|
const sources = createStandaloneLayerSources({
|
||||||
|
flights: source,
|
||||||
|
military: null,
|
||||||
|
});
|
||||||
|
assert.equal(sources.flights, source);
|
||||||
|
assert.equal(sources.military, null);
|
||||||
|
assert.equal(typeof sources.cctv.getCatalog, 'function');
|
||||||
|
assert.throws(
|
||||||
|
() => createStandaloneLayerSources({ fligts: source }),
|
||||||
|
/Unknown source/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty Street Level uses the shared unavailable lifecycle without initialization', async (t) => {
|
||||||
|
const actual = catalog({}, t);
|
||||||
|
const manager = new LayerLifecycle(
|
||||||
|
{},
|
||||||
|
{ getSourceAvailability: actual.getSourceAvailability },
|
||||||
|
);
|
||||||
|
manager.register(actual.get('street-level'));
|
||||||
|
assert.equal(await manager.setEnabled('street-level', true), false);
|
||||||
|
assert.equal(manager.layers.get('street-level').initialized, false);
|
||||||
|
const row = manager.getAll()[0];
|
||||||
|
assert.equal(row.stats.status, 'unavailable');
|
||||||
|
assert.equal(row.stats.error, 'No street-level imagery providers configured');
|
||||||
|
assert.equal(await manager.setEnabled('street-level', false), true);
|
||||||
|
const configured = catalog(createStandaloneLayerSources(), t);
|
||||||
|
assert.equal(
|
||||||
|
configured.get('street-level').getSourceAvailability().available,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('availability lookup accepts frozen layers and uses their display names', () => {
|
||||||
|
const composition = resolveCatalogSources({});
|
||||||
|
const layers = Object.values(CATALOG_SOURCE_CONTRACTS)
|
||||||
|
.flatMap(({ layers }) => layers)
|
||||||
|
.map((id) =>
|
||||||
|
Object.freeze({
|
||||||
|
id,
|
||||||
|
name: id === 'fire-perimeters' ? 'Fire perimeters' : id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const lookup = composition.createAvailabilityLookup(layers);
|
||||||
|
assert.equal(
|
||||||
|
lookup('fire-perimeters').reason,
|
||||||
|
'Fire perimeters: data source not configured',
|
||||||
|
);
|
||||||
|
assert.equal(Object.hasOwn(layers[0], 'getSourceAvailability'), false);
|
||||||
|
const manager = new LayerLifecycle({}, { getSourceAvailability: lookup });
|
||||||
|
manager.register(layers.find(({ id }) => id === 'fire-perimeters'));
|
||||||
|
assert.equal(
|
||||||
|
manager.getAll()[0].stats.error,
|
||||||
|
'Fire perimeters: data source not configured',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('absent military acquisition does not poll, suppress flights, or erase supplied classification', async (t) => {
|
||||||
|
let fetches = 0;
|
||||||
|
t.mock.method(globalThis, 'fetch', async () => {
|
||||||
|
fetches++;
|
||||||
|
throw new Error('Unexpected fallback acquisition');
|
||||||
|
});
|
||||||
|
const actual = catalog(createStandaloneLayerSources({ military: null }), t);
|
||||||
|
const registry = actual.militaryRegistry;
|
||||||
|
assert.equal(actual.getSourceAvailability('flights').available, true);
|
||||||
|
assert.equal(registry.isMilitaryLayerActive(), false);
|
||||||
|
assert.equal(registry.isMilitaryIcao('abc123'), false);
|
||||||
|
registry.registerMilitaryIcaos([' ABC123 ']);
|
||||||
|
await registry.refreshMilitaryRegistryIfStale();
|
||||||
|
assert.equal(registry.isMilitaryIcao('abc123'), true);
|
||||||
|
assert.equal(registry.isMilitaryIcao('def456'), false);
|
||||||
|
const manager = new LayerLifecycle(
|
||||||
|
{},
|
||||||
|
{ getSourceAvailability: actual.getSourceAvailability },
|
||||||
|
);
|
||||||
|
manager.register(actual.get('military'));
|
||||||
|
assert.equal(await manager.setEnabled('military', true), false);
|
||||||
|
assert.equal(registry.isMilitaryLayerActive(), false);
|
||||||
|
assert.equal(fetches, 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import test from 'node:test';
|
||||||
|
import path from 'node:path';
|
||||||
|
import {
|
||||||
|
codexAuthPath,
|
||||||
|
preferCodexOAuth,
|
||||||
|
readCodexOAuthAccessToken,
|
||||||
|
resolveCodexExecutable,
|
||||||
|
startCodexChatGptLogin,
|
||||||
|
} from '../server/providers/openai/codex-auth.js';
|
||||||
|
|
||||||
|
const nowMs = Date.UTC(2026, 8, 27);
|
||||||
|
const jwt = (payload) =>
|
||||||
|
`${Buffer.from('{}').toString('base64url')}.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.signature`;
|
||||||
|
const auth = (token, extras = {}) =>
|
||||||
|
JSON.stringify({
|
||||||
|
auth_mode: 'chatgpt',
|
||||||
|
tokens: { access_token: token, refresh_token: 'refresh-fixture' },
|
||||||
|
...extras,
|
||||||
|
});
|
||||||
|
|
||||||
|
test('optional Codex OAuth pin fails closed on ambiguous values', () => {
|
||||||
|
assert.equal(preferCodexOAuth({}), false);
|
||||||
|
assert.equal(preferCodexOAuth({ GEV_PREFER_CODEX_OAUTH: 'true' }), true);
|
||||||
|
assert.equal(preferCodexOAuth({ GEV_PREFER_CODEX_OAUTH: 'off' }), false);
|
||||||
|
assert.throws(
|
||||||
|
() => preferCodexOAuth({ GEV_PREFER_CODEX_OAUTH: 'maybe' }),
|
||||||
|
/must be true or false/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Codex auth path follows explicit auth file, CODEX_HOME, then home directory', () => {
|
||||||
|
assert.equal(
|
||||||
|
codexAuthPath({
|
||||||
|
environment: { CODEX_AUTH_JSON: '/tmp/custom-auth.json' },
|
||||||
|
home: '/home/fixture',
|
||||||
|
}),
|
||||||
|
path.resolve('/tmp/custom-auth.json'),
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
codexAuthPath({
|
||||||
|
environment: { CODEX_HOME: '/tmp/codex-home' },
|
||||||
|
home: '/home/fixture',
|
||||||
|
}),
|
||||||
|
path.join(path.resolve('/tmp/codex-home'), 'auth.json'),
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
codexAuthPath({ environment: {}, home: '/home/fixture' }),
|
||||||
|
path.join('/home/fixture', '.codex', 'auth.json'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Codex OAuth reader accepts a current ChatGPT token and rejects unusable credentials', () => {
|
||||||
|
const current = jwt({ exp: nowMs / 1000 + 3600 });
|
||||||
|
assert.equal(
|
||||||
|
readCodexOAuthAccessToken({
|
||||||
|
authPath: '/unused',
|
||||||
|
nowMs,
|
||||||
|
readFile: () => auth(current),
|
||||||
|
}),
|
||||||
|
current,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
readCodexOAuthAccessToken({
|
||||||
|
authPath: '/unused',
|
||||||
|
nowMs,
|
||||||
|
readFile: () =>
|
||||||
|
JSON.stringify({
|
||||||
|
auth_mode: 'chatgpt',
|
||||||
|
tokens: { access_token: current },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
current,
|
||||||
|
'GEV does not require or consume the vendor refresh token',
|
||||||
|
);
|
||||||
|
for (const readFile of [
|
||||||
|
() => '{bad json',
|
||||||
|
() => JSON.stringify({ tokens: {} }),
|
||||||
|
() => auth(current, { auth_mode: 'apikey' }),
|
||||||
|
() =>
|
||||||
|
JSON.stringify({
|
||||||
|
OPENAI_API_KEY: 'sk-fixture',
|
||||||
|
tokens: { access_token: current, refresh_token: 'refresh-fixture' },
|
||||||
|
}),
|
||||||
|
() => auth('opaque-token'),
|
||||||
|
() => auth('header.not-json.signature'),
|
||||||
|
() => auth(jwt({ exp: 'tomorrow' })),
|
||||||
|
() => {
|
||||||
|
throw new Error('missing');
|
||||||
|
},
|
||||||
|
]) {
|
||||||
|
assert.throws(
|
||||||
|
() => readCodexOAuthAccessToken({ authPath: '/unused', readFile, nowMs }),
|
||||||
|
/ChatGPT sign-in is unavailable/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Codex OAuth reader enforces expiry margin and bounds tokens without exp by file age', () => {
|
||||||
|
for (const exp of [nowMs / 1000 - 1, nowMs / 1000 + 59]) {
|
||||||
|
assert.throws(
|
||||||
|
() => {
|
||||||
|
readCodexOAuthAccessToken({
|
||||||
|
authPath: '/unused',
|
||||||
|
nowMs,
|
||||||
|
readFile: () => auth(jwt({ exp })),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
assert.match(error.message, /sign-in has expired/);
|
||||||
|
assert.equal(error.code, 'CODEX_OAUTH_REAUTH_REQUIRED');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const noExp = jwt({ sub: 'fixture' });
|
||||||
|
const base = { authPath: '/unused', nowMs, readFile: () => auth(noExp) };
|
||||||
|
assert.equal(
|
||||||
|
readCodexOAuthAccessToken({
|
||||||
|
...base,
|
||||||
|
statFile: () => ({ mtimeMs: nowMs }),
|
||||||
|
}),
|
||||||
|
noExp,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
readCodexOAuthAccessToken({
|
||||||
|
...base,
|
||||||
|
statFile: () => ({ mtimeMs: nowMs - 3600_000 }),
|
||||||
|
}),
|
||||||
|
/sign-in has expired/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
readCodexOAuthAccessToken({
|
||||||
|
...base,
|
||||||
|
statFile: () => {
|
||||||
|
throw new Error('missing');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/sign-in is unavailable/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Codex login resolves an installed executable before PATH and starts browser auth detached', async () => {
|
||||||
|
const checked = [];
|
||||||
|
assert.equal(
|
||||||
|
resolveCodexExecutable({
|
||||||
|
environment: {},
|
||||||
|
home: '/home/fixture',
|
||||||
|
access(candidate) {
|
||||||
|
checked.push(candidate);
|
||||||
|
if (candidate.endsWith('/.local/bin/codex')) return;
|
||||||
|
throw new Error('missing');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
path.join('/home/fixture', '.local', 'bin', 'codex'),
|
||||||
|
);
|
||||||
|
assert.equal(checked.length, 1);
|
||||||
|
|
||||||
|
const child = new EventEmitter();
|
||||||
|
let unref = 0;
|
||||||
|
child.unref = () => {
|
||||||
|
unref += 1;
|
||||||
|
};
|
||||||
|
const calls = [];
|
||||||
|
const pending = startCodexChatGptLogin({
|
||||||
|
executable: '/fixture/codex',
|
||||||
|
home: '/home/fixture',
|
||||||
|
environment: {
|
||||||
|
PATH: '/fixture',
|
||||||
|
https_proxy: 'http://proxy.fixture:8080',
|
||||||
|
OPENAI_API_KEY: 'fixture-api-key',
|
||||||
|
GOOGLE_MAPS_SERVER_API_KEY: 'fixture-google-key',
|
||||||
|
OPENSKY_CLIENT_SECRET: 'fixture-opensky-secret',
|
||||||
|
},
|
||||||
|
spawnImpl(command, args, options) {
|
||||||
|
calls.push({ command, args, options });
|
||||||
|
queueMicrotask(() => child.emit('spawn'));
|
||||||
|
return child;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const login = await pending;
|
||||||
|
assert.equal(unref, 1);
|
||||||
|
assert.equal(calls[0].command, '/fixture/codex');
|
||||||
|
assert.deepEqual(calls[0].args, ['login']);
|
||||||
|
assert.equal(calls[0].options.detached, true);
|
||||||
|
assert.equal(calls[0].options.stdio, 'ignore');
|
||||||
|
assert.deepEqual(
|
||||||
|
calls[0].options.env,
|
||||||
|
{
|
||||||
|
PATH: '/fixture',
|
||||||
|
https_proxy: 'http://proxy.fixture:8080',
|
||||||
|
CODEX_HOME: path.join('/home/fixture', '.codex'),
|
||||||
|
},
|
||||||
|
'provider secrets stay out of the login process',
|
||||||
|
);
|
||||||
|
child.emit('exit', 1);
|
||||||
|
assert.deepEqual(await login.completion, { exitCode: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Codex login refuses a read/write storage mismatch before spawning', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
startCodexChatGptLogin({
|
||||||
|
environment: { CODEX_AUTH_JSON: '/tmp/another-auth.json' },
|
||||||
|
home: '/home/fixture',
|
||||||
|
executable: 'codex',
|
||||||
|
spawnImpl: () => assert.fail('mismatched storage must not launch login'),
|
||||||
|
}),
|
||||||
|
{ code: 'CODEX_OAUTH_STORAGE_MISMATCH' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Codex login resolves relative CODEX_HOME before changing the child directory', async () => {
|
||||||
|
const child = new EventEmitter();
|
||||||
|
const environment = { CODEX_HOME: './fixture-codex' };
|
||||||
|
const login = await startCodexChatGptLogin({
|
||||||
|
environment,
|
||||||
|
executable: 'codex',
|
||||||
|
spawnImpl(_command, _args, options) {
|
||||||
|
assert.equal(
|
||||||
|
options.env.CODEX_HOME,
|
||||||
|
path.resolve(environment.CODEX_HOME),
|
||||||
|
);
|
||||||
|
queueMicrotask(() => child.emit('spawn'));
|
||||||
|
return child;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
environment.CODEX_HOME,
|
||||||
|
'./fixture-codex',
|
||||||
|
'caller environment is unchanged',
|
||||||
|
);
|
||||||
|
child.emit('exit', 0);
|
||||||
|
assert.deepEqual(await login.completion, { exitCode: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Codex login hides spawn errors and can cancel only its owned child', async () => {
|
||||||
|
const child = new EventEmitter();
|
||||||
|
await assert.rejects(
|
||||||
|
startCodexChatGptLogin({
|
||||||
|
executable: 'codex',
|
||||||
|
spawnImpl() {
|
||||||
|
queueMicrotask(() =>
|
||||||
|
child.emit('error', new Error('private-token-fixture')),
|
||||||
|
);
|
||||||
|
return child;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
(error) =>
|
||||||
|
error.code === 'CODEX_OAUTH_LOGIN_FAILED' &&
|
||||||
|
!error.message.includes('private-token-fixture'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const running = new EventEmitter();
|
||||||
|
let cancelled = 0;
|
||||||
|
running.kill = (signal) => {
|
||||||
|
assert.equal(signal, 'SIGTERM');
|
||||||
|
cancelled += 1;
|
||||||
|
running.emit('exit', null);
|
||||||
|
};
|
||||||
|
const login = await startCodexChatGptLogin({
|
||||||
|
executable: 'codex',
|
||||||
|
spawnImpl() {
|
||||||
|
queueMicrotask(() => running.emit('spawn'));
|
||||||
|
return running;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
login.cancel();
|
||||||
|
assert.equal(cancelled, 1);
|
||||||
|
assert.deepEqual(await login.completion, { exitCode: null });
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createDirectionsSource } from '../layers/directions/source.js';
|
||||||
/**
|
/**
|
||||||
* @module directions
|
* @module directions
|
||||||
* @description Service bundle for the Directions layer.
|
* @description Service bundle for the Directions layer.
|
||||||
@@ -37,8 +38,10 @@ export const directionsServices = Object.freeze({
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** Construct one Directions layer over the application scene owners. */
|
/** Construct one Directions layer over the application scene owners. */
|
||||||
export function createApplicationDirectionsLayer() {
|
export function createApplicationDirectionsLayer({
|
||||||
return createDirectionsLayer({ services: directionsServices });
|
source = createDirectionsSource(),
|
||||||
|
} = {}) {
|
||||||
|
return createDirectionsLayer({ services: directionsServices, source });
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
const MAX_SWITCHES_LENGTH = 256;
|
||||||
|
const ENTRY = /^([a-z][a-z0-9-]*)-([01])$/;
|
||||||
|
|
||||||
|
/** Canonical, bounded boolean switches keyed by stable slugs. */
|
||||||
|
export function normalizeIdSwitches(value, reservedIds = []) {
|
||||||
|
if (typeof value !== 'string' || value.length > MAX_SWITCHES_LENGTH)
|
||||||
|
return null;
|
||||||
|
if (!value) return '';
|
||||||
|
const entries = new Map();
|
||||||
|
for (const field of value.split('*')) {
|
||||||
|
const match = ENTRY.exec(field);
|
||||||
|
if (!match || reservedIds.includes(match[1]) || entries.has(match[1]))
|
||||||
|
return null;
|
||||||
|
entries.set(match[1], match[2] === '1');
|
||||||
|
}
|
||||||
|
return [...entries]
|
||||||
|
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||||
|
.map(([id, on]) => `${id}-${on ? '1' : '0'}`)
|
||||||
|
.join('*');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encode by stable IDs rather than registration order or generated tokens. */
|
||||||
|
export function encodeIdSwitches(switches) {
|
||||||
|
const value = [...switches]
|
||||||
|
.map(([id, on]) => `${id}-${on === true ? '1' : '0'}`)
|
||||||
|
.join('*');
|
||||||
|
if (value.length > MAX_SWITCHES_LENGTH)
|
||||||
|
throw new RangeError(
|
||||||
|
`Switch IDs exceed the ${MAX_SWITCHES_LENGTH}-character share-link limit`,
|
||||||
|
);
|
||||||
|
const normalized = normalizeIdSwitches(value);
|
||||||
|
if (normalized === null) throw new TypeError('Invalid boolean switch IDs');
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A Map keeps switch IDs separate from object prototype properties. */
|
||||||
|
export function decodeIdSwitches(value) {
|
||||||
|
const normalized = normalizeIdSwitches(value);
|
||||||
|
return new Map(
|
||||||
|
normalized
|
||||||
|
? normalized.split('*').map((entry) => {
|
||||||
|
const [, id, on] = ENTRY.exec(entry);
|
||||||
|
return [id, on === '1'];
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist only explicitly requested switch IDs, not unrelated live changes. */
|
||||||
|
export function patchRequestedIdSwitches(previous, applied, requested) {
|
||||||
|
const current = decodeIdSwitches(applied);
|
||||||
|
const next = new Map(
|
||||||
|
[...decodeIdSwitches(previous)].filter(([id]) => current.has(id)),
|
||||||
|
);
|
||||||
|
let changed = false;
|
||||||
|
for (const [id, on] of current) {
|
||||||
|
if (Object.hasOwn(requested, id) && typeof requested[id] === 'boolean') {
|
||||||
|
next.set(id, on);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed ? encodeIdSwitches(next) : undefined;
|
||||||
|
}
|
||||||
+11
-1
@@ -1,3 +1,4 @@
|
|||||||
|
import { normalizeIdSwitches, patchRequestedIdSwitches } from './idSwitches.js';
|
||||||
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
|
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
|
||||||
|
|
||||||
const VALID_DISPOSITIONS = new Set([
|
const VALID_DISPOSITIONS = new Set([
|
||||||
@@ -411,8 +412,17 @@ const OPTION_GROUPS = Object.freeze({
|
|||||||
booleanOption('viirs', 'v', false),
|
booleanOption('viirs', 'v', false),
|
||||||
]),
|
]),
|
||||||
'street-level': Object.freeze([
|
'street-level': Object.freeze([
|
||||||
// The Mapillary switch: off, the layer draws nothing.
|
// Preserve the published Mapillary token; custom switches use stable IDs.
|
||||||
booleanOption('mapillary', 'm', true),
|
booleanOption('mapillary', 'm', true),
|
||||||
|
Object.freeze({
|
||||||
|
key: 'providerSwitches',
|
||||||
|
patchRequested: patchRequestedIdSwitches,
|
||||||
|
token: 'r',
|
||||||
|
defaultValue: '',
|
||||||
|
normalize: (value) => normalizeIdSwitches(value, ['mapillary']),
|
||||||
|
encode: normalizeIdSwitches,
|
||||||
|
decode: (value) => normalizeIdSwitches(value, ['mapillary']),
|
||||||
|
}),
|
||||||
enumOption('pano', 'p', 'all', ['all', 'pano', 'flat'], {
|
enumOption('pano', 'p', 'all', ['all', 'pano', 'flat'], {
|
||||||
all: 'a',
|
all: 'a',
|
||||||
pano: 'p',
|
pano: 'p',
|
||||||
|
|||||||
+102
-18
@@ -1,3 +1,4 @@
|
|||||||
|
import { encodeParams, decodeParams } from '../layers/streetLevel/params.js';
|
||||||
import { readShellSource } from '../testSupport/readShellSource.mjs';
|
import { readShellSource } from '../testSupport/readShellSource.mjs';
|
||||||
import { expandApplicationHtml } from '../../build/application-html.js';
|
import { expandApplicationHtml } from '../../build/application-html.js';
|
||||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
||||||
@@ -243,7 +244,11 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
|||||||
}
|
}
|
||||||
assert.equal(nextLayerStateToken(), '3');
|
assert.equal(nextLayerStateToken(), '3');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
nextLayerStateToken({ ...LAYER_STATE_TOKEN_RESERVATIONS, alpha: '3', bravo: '4' }),
|
nextLayerStateToken({
|
||||||
|
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||||
|
alpha: '3',
|
||||||
|
bravo: '4',
|
||||||
|
}),
|
||||||
'5',
|
'5',
|
||||||
);
|
);
|
||||||
const digitsExhausted = {
|
const digitsExhausted = {
|
||||||
@@ -276,7 +281,9 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
|||||||
[
|
[
|
||||||
...'0123456789',
|
...'0123456789',
|
||||||
...[...LAYER_STATE_TOKEN_ALPHABET].flatMap((first) =>
|
...[...LAYER_STATE_TOKEN_ALPHABET].flatMap((first) =>
|
||||||
[...LAYER_STATE_TOKEN_ALPHABET].map((second) => `${first}${second}`),
|
[...LAYER_STATE_TOKEN_ALPHABET].map(
|
||||||
|
(second) => `${first}${second}`,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
].map((token, index) => [`occupied-${index}`, token]),
|
].map((token, index) => [`occupied-${index}`, token]),
|
||||||
),
|
),
|
||||||
@@ -298,33 +305,37 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
validateLayerStateAllocations(
|
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
||||||
LAYER_STATE_TOKEN_RESERVATIONS,
|
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, future: '3', next: '4' },
|
future: '3',
|
||||||
),
|
next: '4',
|
||||||
|
}),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
() =>
|
||||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
||||||
future: '00',
|
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||||
}),
|
future: '00',
|
||||||
|
}),
|
||||||
/next free token 3/,
|
/next free token 3/,
|
||||||
);
|
);
|
||||||
const beforeLastDigit = { ...digitsExhausted };
|
const beforeLastDigit = { ...digitsExhausted };
|
||||||
delete beforeLastDigit['prior-9'];
|
delete beforeLastDigit['prior-9'];
|
||||||
assert.equal(
|
assert.equal(
|
||||||
validateLayerStateAllocations(
|
validateLayerStateAllocations(beforeLastDigit, {
|
||||||
beforeLastDigit,
|
...beforeLastDigit,
|
||||||
{ ...beforeLastDigit, futurePair: '00', futureDigit: '9' },
|
futurePair: '00',
|
||||||
),
|
futureDigit: '9',
|
||||||
|
}),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
validateLayerStateAllocations(
|
validateLayerStateAllocations(digitsExhausted, {
|
||||||
digitsExhausted,
|
...digitsExhausted,
|
||||||
{ ...digitsExhausted, pairB: '01', pairA: '00' },
|
pairB: '01',
|
||||||
),
|
pairA: '00',
|
||||||
|
}),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
@@ -2660,12 +2671,14 @@ test('Street Level: every option round-trips through a share link and stored sta
|
|||||||
state.enabledLayerIds = ['street-level'];
|
state.enabledLayerIds = ['street-level'];
|
||||||
state.options['street-level'] = {
|
state.options['street-level'] = {
|
||||||
mapillary: false,
|
mapillary: false,
|
||||||
|
providerSwitches: '',
|
||||||
pano: 'flat',
|
pano: 'flat',
|
||||||
sinceDays: MAX_SINCE_DAYS,
|
sinceDays: MAX_SINCE_DAYS,
|
||||||
};
|
};
|
||||||
const decoded = decodeLayerStateParams(new URLSearchParams(encode(state)));
|
const decoded = decodeLayerStateParams(new URLSearchParams(encode(state)));
|
||||||
assert.deepEqual(decoded.options['street-level'], {
|
assert.deepEqual(decoded.options['street-level'], {
|
||||||
mapillary: false,
|
mapillary: false,
|
||||||
|
providerSwitches: '',
|
||||||
pano: 'flat',
|
pano: 'flat',
|
||||||
sinceDays: MAX_SINCE_DAYS,
|
sinceDays: MAX_SINCE_DAYS,
|
||||||
});
|
});
|
||||||
@@ -2693,6 +2706,7 @@ test('Street Level: the link codec accepts exactly the windows the filter keeps'
|
|||||||
test('Street Level: a link without the provider switch keeps Mapillary on', () => {
|
test('Street Level: a link without the provider switch keeps Mapillary on', () => {
|
||||||
assert.deepEqual(streetLevelOptions(''), {
|
assert.deepEqual(streetLevelOptions(''), {
|
||||||
mapillary: true,
|
mapillary: true,
|
||||||
|
providerSwitches: '',
|
||||||
pano: 'all',
|
pano: 'all',
|
||||||
sinceDays: 0,
|
sinceDays: 0,
|
||||||
});
|
});
|
||||||
@@ -2703,3 +2717,73 @@ test('Street Level: a link without the provider switch keeps Mapillary on', () =
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('explicit provider switches reach durable state without adopting unrelated live switches', () => {
|
||||||
|
const manager = new DataLayerManager({});
|
||||||
|
const providers = new Map([
|
||||||
|
['mapillary', true],
|
||||||
|
['alpha', true],
|
||||||
|
['beta', true],
|
||||||
|
]);
|
||||||
|
const filter = { pano: 'all', sinceDays: 0 };
|
||||||
|
for (const id of REGISTERED_LAYER_IDS) {
|
||||||
|
manager.register(
|
||||||
|
id === 'street-level'
|
||||||
|
? {
|
||||||
|
...fakeLayer(id),
|
||||||
|
getParams: () => encodeParams({ providers, filter }),
|
||||||
|
setParams(params) {
|
||||||
|
const decoded = decodeParams(params, {
|
||||||
|
providerIds: providers.keys(),
|
||||||
|
filter,
|
||||||
|
});
|
||||||
|
for (const [id, on] of decoded.providers) providers.set(id, on);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: fakeLayer(id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
manager.finalizeRegistrations(LAYER_STATE_REGISTRY);
|
||||||
|
const storage = memoryStorage();
|
||||||
|
const coordinator = new LayerStateCoordinator(manager, shareSink(), {
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
providers.set('beta', false); // Passive state does not belong to the next action.
|
||||||
|
assert.equal(
|
||||||
|
manager.setLayerParams(
|
||||||
|
'street-level',
|
||||||
|
{ alpha: false },
|
||||||
|
{ origin: 'user' },
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
let saved = parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY));
|
||||||
|
assert.equal(saved.options['street-level'].providerSwitches, 'alpha-0');
|
||||||
|
const shared = decodeLayerStateParams(new URLSearchParams(encode(saved)));
|
||||||
|
const restored = decodeParams(shared.options['street-level'], {
|
||||||
|
providerIds: ['beta', 'alpha'],
|
||||||
|
filter,
|
||||||
|
});
|
||||||
|
assert.deepEqual([...restored.providers], [['alpha', false]]);
|
||||||
|
manager.setLayerParams('street-level', { beta: true }, { origin: 'user' });
|
||||||
|
saved = parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY));
|
||||||
|
assert.equal(
|
||||||
|
saved.options['street-level'].providerSwitches,
|
||||||
|
'alpha-0*beta-1',
|
||||||
|
);
|
||||||
|
manager.setLayerParams(
|
||||||
|
'street-level',
|
||||||
|
{ alpha: true },
|
||||||
|
{ origin: 'local-restore' },
|
||||||
|
);
|
||||||
|
saved = parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY));
|
||||||
|
assert.equal(
|
||||||
|
saved.options['street-level'].providerSwitches,
|
||||||
|
'alpha-0*beta-1',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
coordinator.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -255,8 +255,22 @@ export class LayerStateCoordinator {
|
|||||||
const explicitlyRequested = Object.hasOwn(requestedParams, spec.key);
|
const explicitlyRequested = Object.hasOwn(requestedParams, spec.key);
|
||||||
const implicitTrackingSync =
|
const implicitTrackingSync =
|
||||||
!explicitlyRequested && spec.key === trackingOptionKey;
|
!explicitlyRequested && spec.key === trackingOptionKey;
|
||||||
if (!explicitlyRequested && !implicitTrackingSync) continue;
|
// Packed options can persist only the subkeys owned by this request.
|
||||||
const value = spec.normalize(change.params[spec.key]);
|
const requestedValue =
|
||||||
|
explicitlyRequested || implicitTrackingSync
|
||||||
|
? change.params[spec.key]
|
||||||
|
: spec.patchRequested?.(
|
||||||
|
nextOwnerOptions[spec.key],
|
||||||
|
change.params[spec.key],
|
||||||
|
requestedParams,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!explicitlyRequested &&
|
||||||
|
!implicitTrackingSync &&
|
||||||
|
requestedValue === undefined
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
const value = spec.normalize(requestedValue);
|
||||||
if (value === null) {
|
if (value === null) {
|
||||||
if (implicitTrackingSync) {
|
if (implicitTrackingSync) {
|
||||||
nextOwnerOptions[spec.key] = null;
|
nextOwnerOptions[spec.key] = null;
|
||||||
|
|||||||
@@ -98,6 +98,12 @@ function withPublishedBase(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function writeCodecFixture(cwd, rows, entries) {
|
function writeCodecFixture(cwd, rows, entries) {
|
||||||
|
const providerCodec = path.join(cwd, 'src/data/idSwitches.js');
|
||||||
|
mkdirSync(path.dirname(providerCodec), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
providerCodec,
|
||||||
|
readFileSync(new URL('./idSwitches.js', import.meta.url)),
|
||||||
|
);
|
||||||
const source = readFileSync(CODEC_SOURCE_PATH, 'utf8');
|
const source = readFileSync(CODEC_SOURCE_PATH, 'utf8');
|
||||||
const registryEnd = ']);\n\nexport const REGISTERED_LAYER_IDS';
|
const registryEnd = ']);\n\nexport const REGISTERED_LAYER_IDS';
|
||||||
assert.equal(source.split(registryEnd).length, 2);
|
assert.equal(source.split(registryEnd).length, 2);
|
||||||
|
|||||||
+23
-1
@@ -74,7 +74,11 @@ function refreshFailureFromStats(stats, label) {
|
|||||||
* for real-time data overlays on the CesiumJS globe.
|
* for real-time data overlays on the CesiumJS globe.
|
||||||
*/
|
*/
|
||||||
export class LayerLifecycle {
|
export class LayerLifecycle {
|
||||||
constructor(viewer, { allowQaRegistration = false } = {}) {
|
constructor(
|
||||||
|
viewer,
|
||||||
|
{ allowQaRegistration = false, getSourceAvailability } = {},
|
||||||
|
) {
|
||||||
|
this._catalogSourceAvailability = getSourceAvailability;
|
||||||
this.viewer = viewer;
|
this.viewer = viewer;
|
||||||
this._activityListeners = new Set();
|
this._activityListeners = new Set();
|
||||||
this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain }
|
this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain }
|
||||||
@@ -228,6 +232,7 @@ export class LayerLifecycle {
|
|||||||
|
|
||||||
_normalizedStats(entry) {
|
_normalizedStats(entry) {
|
||||||
const moduleStats = this._moduleStats(entry);
|
const moduleStats = this._moduleStats(entry);
|
||||||
|
const availability = this._sourceAvailability(entry.module.id);
|
||||||
const lifecycleLoading =
|
const lifecycleLoading =
|
||||||
entry.lifecycleState === 'enabling' ||
|
entry.lifecycleState === 'enabling' ||
|
||||||
entry.lifecycleState === 'disabling';
|
entry.lifecycleState === 'disabling';
|
||||||
@@ -235,6 +240,13 @@ export class LayerLifecycle {
|
|||||||
count: 0,
|
count: 0,
|
||||||
lastUpdate: null,
|
lastUpdate: null,
|
||||||
...moduleStats,
|
...moduleStats,
|
||||||
|
...(availability?.available === false
|
||||||
|
? {
|
||||||
|
status: 'unavailable',
|
||||||
|
sourceUnavailable: true,
|
||||||
|
error: availability.reason,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
loading: lifecycleLoading || moduleStats.loading === true,
|
loading: lifecycleLoading || moduleStats.loading === true,
|
||||||
refreshing: entry.refreshing || moduleStats.refreshing === true,
|
refreshing: entry.refreshing || moduleStats.refreshing === true,
|
||||||
managerRefreshError: entry.managerRefreshError,
|
managerRefreshError: entry.managerRefreshError,
|
||||||
@@ -2282,7 +2294,17 @@ export class LayerLifecycle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_sourceAvailability(layerId) {
|
||||||
|
return (
|
||||||
|
this._catalogSourceAvailability?.(layerId) ??
|
||||||
|
this.layers.get(layerId)?.module.getSourceAvailability?.()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async _visibilityBlockReason(change) {
|
async _visibilityBlockReason(change) {
|
||||||
|
const availability = this._sourceAvailability(change.layerId);
|
||||||
|
if (change.enabled && availability?.available === false)
|
||||||
|
return availability.reason || 'Data source unavailable';
|
||||||
for (const callback of this._visibilityGuards) {
|
for (const callback of this._visibilityGuards) {
|
||||||
try {
|
try {
|
||||||
const result = await callback(change);
|
const result = await callback(change);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { createBhoteKoshiLocatorLayer } from './bhoteKoshiLocator.js';
|
|||||||
import { createWorldAnnotationRenderer } from '../annotations/worldAnnotationRenderer.js';
|
import { createWorldAnnotationRenderer } from '../annotations/worldAnnotationRenderer.js';
|
||||||
import * as annotations from '../annotations/worldAnnotationRenderer.js';
|
import * as annotations from '../annotations/worldAnnotationRenderer.js';
|
||||||
import { createDirectionsLayer } from '../layers/directions/index.js';
|
import { createDirectionsLayer } from '../layers/directions/index.js';
|
||||||
|
import { createDirectionsSource } from '../layers/directions/source.js';
|
||||||
import { createLifecycle as createCctvLifecycle } from '../layers/cctv/lifecycle.js';
|
import { createLifecycle as createCctvLifecycle } from '../layers/cctv/lifecycle.js';
|
||||||
import * as input from './inputOwnership.js';
|
import * as input from './inputOwnership.js';
|
||||||
import { createOverpassAlprSource } from '../layers/alpr/source.js';
|
import { createOverpassAlprSource } from '../layers/alpr/source.js';
|
||||||
@@ -64,7 +65,7 @@ test('voice route geometry introduces the shared OSM credit', (t) => {
|
|||||||
test('Directions introduces the shared OSM credit when the requested route draws', async (t) => {
|
test('Directions introduces the shared OSM credit when the requested route draws', async (t) => {
|
||||||
const v = viewer(t);
|
const v = viewer(t);
|
||||||
t.mock.method(globalThis, 'fetch', async () => Response.json({ ok: true, geometry: [[0, 0], [0.01, 0.01]], steps: [] }));
|
t.mock.method(globalThis, 'fetch', async () => Response.json({ ok: true, geometry: [[0, 0], [0.01, 0.01]], steps: [] }));
|
||||||
const layer = createDirectionsLayer({ services: {
|
const layer = createDirectionsLayer({ source: createDirectionsSource(), services: {
|
||||||
credits: { showOsmCredit, hideOsmCredit }, annotations, input, scenePick: {}, camera: {},
|
credits: { showOsmCredit, hideOsmCredit }, annotations, input, scenePick: {}, camera: {},
|
||||||
overlays: { setOverlayEntries: noop, setOverlaySourceVisible: noop, clearOverlaySource: noop },
|
overlays: { setOverlayEntries: noop, setOverlaySourceVisible: noop, clearOverlaySource: noop },
|
||||||
render: { governorRequestRender: noop, holdContinuousRender: noop, releaseContinuousRender: noop },
|
render: { governorRequestRender: noop, holdContinuousRender: noop, releaseContinuousRender: noop },
|
||||||
|
|||||||
+179
@@ -1,4 +1,8 @@
|
|||||||
import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js';
|
import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js';
|
||||||
|
import {
|
||||||
|
readStoredCloudVoiceAuthMode,
|
||||||
|
writeStoredCloudVoiceAuthMode,
|
||||||
|
} from './voice/cloudVoiceAuth.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The POWER UP surface — paste a key, get a power.
|
* The POWER UP surface — paste a key, get a power.
|
||||||
@@ -38,6 +42,52 @@ export function collectKeyUpdates(fields) {
|
|||||||
return updates;
|
return updates;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sleepWithSignal(ms, signal) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
reject(signal.reason || new DOMException('Aborted', 'AbortError'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const onAbort = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(signal.reason || new DOMException('Aborted', 'AbortError'));
|
||||||
|
};
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
signal?.removeEventListener('abort', onAbort);
|
||||||
|
resolve();
|
||||||
|
}, ms);
|
||||||
|
signal?.addEventListener('abort', onAbort, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForChatGptOAuth({
|
||||||
|
fetchImpl,
|
||||||
|
signal,
|
||||||
|
timeoutMs = 120_000,
|
||||||
|
pollMs = 1_000,
|
||||||
|
now = () => Date.now(),
|
||||||
|
sleep = sleepWithSignal,
|
||||||
|
} = {}) {
|
||||||
|
const deadline = now() + Math.max(0, timeoutMs);
|
||||||
|
while (!signal?.aborted && now() <= deadline) {
|
||||||
|
const response = await fetchImpl('/api/realtime/oauth-status', {
|
||||||
|
cache: 'no-store',
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (signal?.aborted) return false;
|
||||||
|
if (response.ok && payload.available) return true;
|
||||||
|
if (!response.ok || payload.loginFailed) {
|
||||||
|
throw new Error(
|
||||||
|
payload.error || 'Could not check ChatGPT sign-in. Try again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (now() >= deadline) break;
|
||||||
|
await sleep(Math.max(0, pollMs), signal);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* After the FIRST Google key lands, the restart's reload should boot the
|
* After the FIRST Google key lands, the restart's reload should boot the
|
||||||
* photoreal default — not faithfully restore the auto-selected keyless OSM
|
* photoreal default — not faithfully restore the auto-selected keyless OSM
|
||||||
@@ -61,6 +111,21 @@ export function stripKeylessBasemapFromHash(hash) {
|
|||||||
|
|
||||||
const TIER_DOTS = Object.freeze({ metered: '🔴', free: '🟡' });
|
const TIER_DOTS = Object.freeze({ metered: '🔴', free: '🟡' });
|
||||||
|
|
||||||
|
function updateCloudVoiceAuthControl(auth, busy = false) {
|
||||||
|
const mode = readStoredCloudVoiceAuthMode();
|
||||||
|
const state = auth.querySelector('.key-setup-cloud-auth-state');
|
||||||
|
const toggle = auth.querySelector('[data-cloud-voice-auth-toggle]');
|
||||||
|
state.textContent =
|
||||||
|
mode === 'oauth' ? 'VOICE AUTH · CHATGPT OAUTH' : 'VOICE AUTH · API KEY';
|
||||||
|
toggle.textContent = mode === 'oauth' ? 'USE API KEY' : 'USE CHATGPT OAUTH';
|
||||||
|
toggle.title =
|
||||||
|
mode === 'oauth'
|
||||||
|
? 'Use OPENAI_API_KEY for the next cloud voice session'
|
||||||
|
: 'Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session';
|
||||||
|
// Keep the control focusable while the delegated handler blocks repeats.
|
||||||
|
toggle.setAttribute('aria-disabled', String(busy));
|
||||||
|
}
|
||||||
|
|
||||||
/** Build one key row. All content is our own registry text, set via textContent. */
|
/** Build one key row. All content is our own registry text, set via textContent. */
|
||||||
function buildRow(documentRef, key) {
|
function buildRow(documentRef, key) {
|
||||||
const row = documentRef.createElement('section');
|
const row = documentRef.createElement('section');
|
||||||
@@ -144,6 +209,19 @@ function buildRow(documentRef, key) {
|
|||||||
}
|
}
|
||||||
row.append(fields);
|
row.append(fields);
|
||||||
}
|
}
|
||||||
|
if (key.id === 'openai') {
|
||||||
|
const auth = documentRef.createElement('div');
|
||||||
|
auth.className = 'key-setup-cloud-auth';
|
||||||
|
const state = documentRef.createElement('span');
|
||||||
|
state.className = 'key-setup-cloud-auth-state';
|
||||||
|
const toggle = documentRef.createElement('button');
|
||||||
|
toggle.type = 'button';
|
||||||
|
toggle.className = 'key-setup-cloud-auth-toggle';
|
||||||
|
toggle.dataset.cloudVoiceAuthToggle = 'true';
|
||||||
|
auth.append(state, toggle);
|
||||||
|
updateCloudVoiceAuthControl(auth);
|
||||||
|
row.append(auth);
|
||||||
|
}
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,8 +302,14 @@ export async function initKeySetup({
|
|||||||
const statusLine = root.querySelector('[data-key-setup-status]');
|
const statusLine = root.querySelector('[data-key-setup-status]');
|
||||||
const defaultStatusText = statusLine?.textContent || '';
|
const defaultStatusText = statusLine?.textContent || '';
|
||||||
let busy = false;
|
let busy = false;
|
||||||
|
let oauthBusy = false;
|
||||||
let open = false;
|
let open = false;
|
||||||
|
|
||||||
|
const syncCloudVoiceAuth = () => {
|
||||||
|
for (const auth of rowsHost.querySelectorAll('.key-setup-cloud-auth'))
|
||||||
|
updateCloudVoiceAuthControl(auth, oauthBusy);
|
||||||
|
};
|
||||||
|
|
||||||
const render = (nextStatus) => {
|
const render = (nextStatus) => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
status = nextStatus;
|
status = nextStatus;
|
||||||
@@ -239,6 +323,7 @@ export async function initKeySetup({
|
|||||||
rowsHost.textContent = '';
|
rowsHost.textContent = '';
|
||||||
for (const key of status.keys || [])
|
for (const key of status.keys || [])
|
||||||
rowsHost.append(buildRow(documentRef, key));
|
rowsHost.append(buildRow(documentRef, key));
|
||||||
|
syncCloudVoiceAuth();
|
||||||
};
|
};
|
||||||
|
|
||||||
const visible = () =>
|
const visible = () =>
|
||||||
@@ -367,6 +452,100 @@ export async function initKeySetup({
|
|||||||
applyButton?.addEventListener('click', onApply);
|
applyButton?.addEventListener('click', onApply);
|
||||||
// Remove buttons are rendered per row; delegate so re-renders stay wired.
|
// Remove buttons are rendered per row; delegate so re-renders stay wired.
|
||||||
rowsHost?.addEventListener('click', (event) => {
|
rowsHost?.addEventListener('click', (event) => {
|
||||||
|
const authButton = event.target?.closest?.(
|
||||||
|
'[data-cloud-voice-auth-toggle]',
|
||||||
|
);
|
||||||
|
if (authButton && !disposed) {
|
||||||
|
if (oauthBusy) return;
|
||||||
|
void (async () => {
|
||||||
|
const current = readStoredCloudVoiceAuthMode();
|
||||||
|
if (current === 'oauth') {
|
||||||
|
writeStoredCloudVoiceAuthMode('api-key');
|
||||||
|
syncCloudVoiceAuth();
|
||||||
|
say('Cloud voice will use OPENAI_API_KEY on the next session.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
oauthBusy = true;
|
||||||
|
syncCloudVoiceAuth();
|
||||||
|
say('Checking local ChatGPT OAuth sign-in…');
|
||||||
|
try {
|
||||||
|
const response = await doFetch('/api/realtime/oauth-status', {
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: lifetime.signal,
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (lifetime.signal.aborted) return;
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(
|
||||||
|
payload.error || 'Could not check ChatGPT sign-in.',
|
||||||
|
);
|
||||||
|
if (payload.available) {
|
||||||
|
writeStoredCloudVoiceAuthMode('oauth');
|
||||||
|
syncCloudVoiceAuth();
|
||||||
|
say(
|
||||||
|
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
say(
|
||||||
|
payload.code === 'CODEX_OAUTH_REAUTH_REQUIRED'
|
||||||
|
? 'ChatGPT sign-in expired. Opening sign-in again…'
|
||||||
|
: 'Opening ChatGPT sign-in in your browser…',
|
||||||
|
);
|
||||||
|
const loginResponse = await doFetch('/api/realtime/oauth-login', {
|
||||||
|
method: 'POST',
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: lifetime.signal,
|
||||||
|
});
|
||||||
|
const loginPayload = await loginResponse.json().catch(() => ({}));
|
||||||
|
if (lifetime.signal.aborted) return;
|
||||||
|
if (!loginResponse.ok) {
|
||||||
|
say(
|
||||||
|
loginPayload.error ||
|
||||||
|
payload.error ||
|
||||||
|
'Could not start ChatGPT sign-in on this machine.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (loginPayload.available) {
|
||||||
|
writeStoredCloudVoiceAuthMode('oauth');
|
||||||
|
syncCloudVoiceAuth();
|
||||||
|
say(
|
||||||
|
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
say(
|
||||||
|
'Finish ChatGPT sign-in in the browser. Waiting for it to complete…',
|
||||||
|
);
|
||||||
|
const available = await waitForChatGptOAuth({
|
||||||
|
fetchImpl: doFetch,
|
||||||
|
signal: lifetime.signal,
|
||||||
|
});
|
||||||
|
if (lifetime.signal.aborted) return;
|
||||||
|
if (!available) {
|
||||||
|
say(
|
||||||
|
'ChatGPT sign-in timed out. Click USE CHATGPT OAUTH to try again.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writeStoredCloudVoiceAuthMode('oauth');
|
||||||
|
syncCloudVoiceAuth();
|
||||||
|
say(
|
||||||
|
'ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (lifetime.signal.aborted) return;
|
||||||
|
say(`OAuth check failed: ${error?.message || error}`);
|
||||||
|
} finally {
|
||||||
|
oauthBusy = false;
|
||||||
|
if (!disposed) syncCloudVoiceAuth();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const button = event.target?.closest?.('[data-key-setup-remove]');
|
const button = event.target?.closest?.('[data-key-setup-remove]');
|
||||||
if (disposed || !button || busy) return;
|
if (disposed || !button || busy) return;
|
||||||
let envVars = [];
|
let envVars = [];
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
keySetupChipLabel,
|
keySetupChipLabel,
|
||||||
stripKeylessBasemapFromHash,
|
stripKeylessBasemapFromHash,
|
||||||
bindKeySetupPlacement,
|
bindKeySetupPlacement,
|
||||||
|
waitForChatGptOAuth,
|
||||||
} from './keySetup.js';
|
} from './keySetup.js';
|
||||||
|
|
||||||
test('setup placement moves the same button only in Cyber and disconnects on teardown', () => {
|
test('setup placement moves the same button only in Cyber and disconnects on teardown', () => {
|
||||||
@@ -61,6 +62,64 @@ test('collectKeyUpdates keeps only non-empty trimmed values', () => {
|
|||||||
assert.deepEqual(collectKeyUpdates(null), {});
|
assert.deepEqual(collectKeyUpdates(null), {});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('OAuth login polling stops as soon as local ChatGPT auth becomes available', async () => {
|
||||||
|
let checks = 0;
|
||||||
|
let clock = 0;
|
||||||
|
const available = await waitForChatGptOAuth({
|
||||||
|
fetchImpl: async () => {
|
||||||
|
checks += 1;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ available: checks >= 3 }),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
timeoutMs: 10_000,
|
||||||
|
pollMs: 100,
|
||||||
|
now: () => clock,
|
||||||
|
sleep: async (ms) => {
|
||||||
|
clock += ms;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(available, true);
|
||||||
|
assert.equal(checks, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OAuth login polling stops on login or HTTP failure instead of waiting for timeout', async () => {
|
||||||
|
for (const result of [
|
||||||
|
{ ok: true, payload: { available: false, loginFailed: true, error: 'Sign-in failed.' } },
|
||||||
|
{ ok: false, payload: { error: 'Sign-in is local only.' } },
|
||||||
|
]) {
|
||||||
|
let checks = 0;
|
||||||
|
await assert.rejects(waitForChatGptOAuth({
|
||||||
|
fetchImpl: async () => { checks += 1; return { ok: result.ok, json: async () => result.payload }; },
|
||||||
|
sleep: () => assert.fail('failed login must stop polling'),
|
||||||
|
}), { message: result.payload.error });
|
||||||
|
assert.equal(checks, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OAuth login polling respects timeout and an aborted setup surface', async () => {
|
||||||
|
let clock = 0;
|
||||||
|
let checks = 0;
|
||||||
|
const poll = { fetchImpl: async () => { checks += 1; return { ok: true, json: async () => ({ available: false, pending: true }) }; } };
|
||||||
|
assert.equal(await waitForChatGptOAuth({ ...poll, timeoutMs: 100, pollMs: 100, now: () => clock, sleep: async (ms) => { clock += ms; } }), false);
|
||||||
|
assert.equal(checks, 2);
|
||||||
|
const controller = new AbortController();
|
||||||
|
controller.abort();
|
||||||
|
assert.equal(await waitForChatGptOAuth({ ...poll, signal: controller.signal }), false);
|
||||||
|
assert.equal(checks, 2, 'aborted polling does not fetch');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OAuth polling ignores a ready response received after the surface was aborted', async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const available = await waitForChatGptOAuth({
|
||||||
|
signal: controller.signal,
|
||||||
|
fetchImpl: async () => ({ ok: true, json: async () => { controller.abort(); return { available: true }; } }),
|
||||||
|
sleep: () => assert.fail('aborted polling must not continue'),
|
||||||
|
});
|
||||||
|
assert.equal(available, false);
|
||||||
|
});
|
||||||
|
|
||||||
test('the first Google key strips ONLY the keyless OSM basemap from the share hash', () => {
|
test('the first Google key strips ONLY the keyless OSM basemap from the share hash', () => {
|
||||||
const stripped = stripKeylessBasemapFromHash('lat=30.2&lon=-97.7&map=osm&style=normal');
|
const stripped = stripKeylessBasemapFromHash('lat=30.2&lon=-97.7&map=osm&style=normal');
|
||||||
assert.ok(stripped !== null);
|
assert.ok(stripped !== null);
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
|||||||
Object.freeze({
|
Object.freeze({
|
||||||
id: 'openai',
|
id: 'openai',
|
||||||
title: 'OPENAI',
|
title: 'OPENAI',
|
||||||
unlocks: 'Voice control — talk to the planet',
|
unlocks: 'Voice control — API key or ChatGPT OAuth',
|
||||||
getUrl: 'https://platform.openai.com/api-keys',
|
getUrl: 'https://platform.openai.com/api-keys',
|
||||||
envVars: Object.freeze(['OPENAI_API_KEY']),
|
envVars: Object.freeze(['OPENAI_API_KEY']),
|
||||||
tier: 'metered',
|
tier: 'metered',
|
||||||
|
|||||||
@@ -452,10 +452,7 @@ export function createDirectionsStepOverlayEntry(index, position, copy) {
|
|||||||
* @param {{lat:number, lon:number}} b
|
* @param {{lat:number, lon:number}} b
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function directionsRequestUrl(mode, a, b) {
|
export { directionsRequestUrl } from './source.js';
|
||||||
const coords = `${a.lon.toFixed(6)},${a.lat.toFixed(6)};${b.lon.toFixed(6)},${b.lat.toFixed(6)}`;
|
|
||||||
return `/api/route?profile=${encodeURIComponent(mode)}&coords=${encodeURIComponent(coords)}&steps=1`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate a proxy payload into the route record the layer keeps, or null.
|
* Validate a proxy payload into the route record the layer keeps, or null.
|
||||||
@@ -509,10 +506,12 @@ export function normalizeRoutePayload(payload, mode) {
|
|||||||
* route, the markers, the pointer claim or the flight this layer owns. The
|
* route, the markers, the pointer claim or the flight this layer owns. The
|
||||||
* shared services are injected, which is what keeps this module's own imports
|
* shared services are injected, which is what keeps this module's own imports
|
||||||
* down to Cesium and the pure step formatter.
|
* down to Cesium and the pure step formatter.
|
||||||
* @param {{services: object}} options
|
* @param {{services: object, source: {getRoute: Function}}} options
|
||||||
* @returns {object} The layer module the data manager registers.
|
* @returns {object} The layer module the data manager registers.
|
||||||
*/
|
*/
|
||||||
export function createDirectionsLayer({ services }) {
|
export function createDirectionsLayer({ services, source }) {
|
||||||
|
if (typeof source?.getRoute !== 'function')
|
||||||
|
throw new TypeError('Directions require a route source');
|
||||||
/**
|
/**
|
||||||
* Where the maneuver card is drawn. Defaults to the shared world-overlay host;
|
* Where the maneuver card is drawn. Defaults to the shared world-overlay host;
|
||||||
* tests swap it. Resolved on use, not at import, because the services arrive
|
* tests swap it. Resolved on use, not at import, because the services arrive
|
||||||
@@ -827,18 +826,12 @@ export function createDirectionsLayer({ services }) {
|
|||||||
_error = null;
|
_error = null;
|
||||||
notifyRow();
|
notifyRow();
|
||||||
try {
|
try {
|
||||||
const response = await fetch(directionsRequestUrl(mode, _a, _b), {
|
const payload = await source.getRoute(
|
||||||
signal: controller.signal,
|
{ mode, a: _a, b: _b },
|
||||||
headers: { Accept: 'application/json' },
|
{
|
||||||
});
|
signal: controller.signal,
|
||||||
const payload = await response.json();
|
},
|
||||||
if (response.status === 429) {
|
);
|
||||||
throw new Error(
|
|
||||||
typeof payload?.error === 'string' && payload.error
|
|
||||||
? `${payload.error} — try again in a moment`
|
|
||||||
: 'Routing is rate limited — try again in a moment',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (seq !== _routeSeq || !_enabled) return;
|
if (seq !== _routeSeq || !_enabled) return;
|
||||||
const route = normalizeRoutePayload(payload, mode);
|
const route = normalizeRoutePayload(payload, mode);
|
||||||
if (!route) {
|
if (!route) {
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/** Build the existing route-proxy query from geographic endpoints. */
|
||||||
|
export function directionsRequestUrl(mode, a, b) {
|
||||||
|
const coords = `${a.lon.toFixed(6)},${a.lat.toFixed(6)};${b.lon.toFixed(6)},${b.lat.toFixed(6)}`;
|
||||||
|
return `/api/route?profile=${encodeURIComponent(mode)}&coords=${encodeURIComponent(coords)}&steps=1`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Acquire route records using caller-owned transport and an optional endpoint. */
|
||||||
|
export function createDirectionsSource({
|
||||||
|
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||||
|
endpoint = '/api/route',
|
||||||
|
} = {}) {
|
||||||
|
return {
|
||||||
|
async getRoute({ mode, a, b }, { signal } = {}) {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
const query = directionsRequestUrl(mode, a, b).split('?')[1];
|
||||||
|
const response = await fetchImpl(`${endpoint}?${query}`, {
|
||||||
|
signal,
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
const payload = await response.json();
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
if (response.status === 429) {
|
||||||
|
throw new Error(
|
||||||
|
typeof payload?.error === 'string' && payload.error
|
||||||
|
? `${payload.error} — try again in a moment`
|
||||||
|
: 'Routing is rate limited — try again in a moment',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!response.ok && !payload?.error)
|
||||||
|
throw new Error(`Routing unavailable (HTTP ${response.status})`);
|
||||||
|
return payload;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createDirectionsSource } from './source.js';
|
||||||
|
|
||||||
|
const query = { mode: 'foot', a: { lon: 1, lat: 2 }, b: { lon: 3, lat: 4 } };
|
||||||
|
|
||||||
|
test('route acquisition uses only the supplied transport and endpoint', async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const payload = { ok: true, geometry: [[1, 2], [3, 4]] };
|
||||||
|
const calls = [];
|
||||||
|
const source = createDirectionsSource({
|
||||||
|
endpoint: 'https://routes.example/route',
|
||||||
|
fetchImpl: async (...args) => { calls.push(args); return Response.json(payload); },
|
||||||
|
});
|
||||||
|
assert.deepEqual(await source.getRoute(query, { signal: controller.signal }), payload);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
const [url, init] = calls[0];
|
||||||
|
assert.equal(new URL(url).origin, 'https://routes.example');
|
||||||
|
assert.equal(new URL(url).searchParams.get('coords'), '1.000000,2.000000;3.000000,4.000000');
|
||||||
|
assert.equal(new URL(url).searchParams.get('profile'), 'foot');
|
||||||
|
assert.equal(init.signal, controller.signal);
|
||||||
|
assert.equal(init.headers.Accept, 'application/json');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aborting during route parsing discards the result', async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const source = createDirectionsSource({ fetchImpl: async () => ({
|
||||||
|
ok: true, json: async () => { controller.abort(); return { ok: true }; },
|
||||||
|
}) });
|
||||||
|
await assert.rejects(source.getRoute(query, { signal: controller.signal }), { name: 'AbortError' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('route failures are surfaced without fallback requests', async () => {
|
||||||
|
for (const status of [429, 503]) {
|
||||||
|
let calls = 0;
|
||||||
|
const source = createDirectionsSource({ fetchImpl: async () => {
|
||||||
|
calls++;
|
||||||
|
return Response.json({}, { status });
|
||||||
|
} });
|
||||||
|
await assert.rejects(source.getRoute(query), status === 429 ? /rate limited/ : /HTTP 503/);
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -70,6 +70,8 @@ function admitTileRequests(provider, admission, requestRender, retry) {
|
|||||||
*/
|
*/
|
||||||
export function createRecentImageryRenderer({
|
export function createRecentImageryRenderer({
|
||||||
cesium = Cesium,
|
cesium = Cesium,
|
||||||
|
tileTemplate = gibsTemplate,
|
||||||
|
credit = 'NASA GIBS',
|
||||||
maxTileRequests = 6,
|
maxTileRequests = 6,
|
||||||
requestRender = () => {},
|
requestRender = () => {},
|
||||||
setTimeoutImpl = (fn, ms) => globalThis.setTimeout(fn, ms),
|
setTimeoutImpl = (fn, ms) => globalThis.setTimeout(fn, ms),
|
||||||
@@ -101,7 +103,7 @@ export function createRecentImageryRenderer({
|
|||||||
function createProvider(candidate, box) {
|
function createProvider(candidate, box) {
|
||||||
const spec = PRODUCTS[candidate.product];
|
const spec = PRODUCTS[candidate.product];
|
||||||
const provider = new cesium.UrlTemplateImageryProvider({
|
const provider = new cesium.UrlTemplateImageryProvider({
|
||||||
url: gibsTemplate(candidate.product, candidate.day),
|
url: tileTemplate(candidate.product, candidate.day),
|
||||||
subdomains: ['a', 'b', 'c'],
|
subdomains: ['a', 'b', 'c'],
|
||||||
minimumLevel: 0,
|
minimumLevel: 0,
|
||||||
maximumLevel: spec.maxLevel,
|
maximumLevel: spec.maxLevel,
|
||||||
@@ -112,7 +114,7 @@ export function createRecentImageryRenderer({
|
|||||||
box.east,
|
box.east,
|
||||||
box.north,
|
box.north,
|
||||||
),
|
),
|
||||||
credit: 'NASA GIBS',
|
credit,
|
||||||
hasAlphaChannel: spec.format === 'png',
|
hasAlphaChannel: spec.format === 'png',
|
||||||
});
|
});
|
||||||
return admitTileRequests(provider, admission, requestRender, retry);
|
return admitTileRequests(provider, admission, requestRender, retry);
|
||||||
|
|||||||
@@ -53,10 +53,11 @@ const S30 = { key: 'S30:2026-09-18', product: 'S30', day: '2026-09-18' };
|
|||||||
const L30 = { key: 'L30:2026-09-16', product: 'L30', day: '2026-09-16' };
|
const L30 = { key: 'L30:2026-09-16', product: 'L30', day: '2026-09-16' };
|
||||||
const VIIRS = { key: 'VIIRS:2026-09-20', product: 'VIIRS', day: '2026-09-20' };
|
const VIIRS = { key: 'VIIRS:2026-09-20', product: 'VIIRS', day: '2026-09-20' };
|
||||||
|
|
||||||
function fixture({ requestImage, maxTileRequests } = {}) {
|
function fixture({ requestImage, maxTileRequests, ...options } = {}) {
|
||||||
const renders = [];
|
const renders = [];
|
||||||
const timers = manualTimers();
|
const timers = manualTimers();
|
||||||
const renderer = createRecentImageryRenderer({
|
const renderer = createRecentImageryRenderer({
|
||||||
|
...options,
|
||||||
cesium: fakeCesium(requestImage),
|
cesium: fakeCesium(requestImage),
|
||||||
maxTileRequests,
|
maxTileRequests,
|
||||||
requestRender: (reason) => renders.push(reason),
|
requestRender: (reason) => renders.push(reason),
|
||||||
@@ -208,3 +209,21 @@ test('against the basemap slot a splits left with no second layer, and leaving t
|
|||||||
assert.equal(layer.splitDirection, 0);
|
assert.equal(layer.splitDirection, 0);
|
||||||
assert.deepEqual(renders, ['recent-imagery-show', 'recent-imagery-look']);
|
assert.deepEqual(renders, ['recent-imagery-show', 'recent-imagery-look']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('replacement imagery uses source-owned tile URLs and credit and tears down normally', () => {
|
||||||
|
const { renderer, globe } = fixture({
|
||||||
|
tileTemplate: (product, day) =>
|
||||||
|
`https://tiles.example/${product}/${day}/{z}/{x}/{y}`,
|
||||||
|
credit: 'Example imagery',
|
||||||
|
});
|
||||||
|
assert.equal(renderer.showSlot('a', S30, BOX), true);
|
||||||
|
const layer = globe.layers[0];
|
||||||
|
assert.equal(
|
||||||
|
layer.provider.options.url,
|
||||||
|
'https://tiles.example/S30/2026-09-18/{z}/{x}/{y}',
|
||||||
|
);
|
||||||
|
assert.equal(layer.provider.options.credit, 'Example imagery');
|
||||||
|
renderer.destroy();
|
||||||
|
assert.equal(globe.layers.length, 0);
|
||||||
|
assert.equal(layer.destroyed, true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { searchHls } from './catalog.js';
|
||||||
|
import { gibsTemplate, wvsSnapshotUrl } from './model.js';
|
||||||
|
|
||||||
|
/** NASA-compatible imagery acquisition; transport and URL builders are replaceable. */
|
||||||
|
export function createRecentImagerySource({
|
||||||
|
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||||
|
search = searchHls,
|
||||||
|
thumbnailUrl = wvsSnapshotUrl,
|
||||||
|
tileTemplate = gibsTemplate,
|
||||||
|
credit = 'NASA GIBS',
|
||||||
|
} = {}) {
|
||||||
|
return {
|
||||||
|
credit,
|
||||||
|
searchHls: (query) => search({ ...query, fetchImpl }),
|
||||||
|
async getThumbnail(candidate, box, { size = 256, signal } = {}) {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
const response = await fetchImpl(
|
||||||
|
thumbnailUrl({
|
||||||
|
product: candidate.product,
|
||||||
|
day: candidate.day,
|
||||||
|
box,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
}),
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
getTileTemplate: (product, day) => tileTemplate(product, day),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createRecentImagerySource } from './source.js';
|
||||||
|
|
||||||
|
test('imagery source routes catalog, thumbnails and tiles through supplied adapters', async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const query = { box: { west: 0, east: 1, south: 0, north: 1 }, signal: controller.signal };
|
||||||
|
const candidate = { product: 'S30', day: '2026-10-01' };
|
||||||
|
const calls = [];
|
||||||
|
const fetchImpl = async (...args) => { calls.push(args); return new Response('image'); };
|
||||||
|
const source = createRecentImagerySource({
|
||||||
|
fetchImpl,
|
||||||
|
search: async (request) => {
|
||||||
|
assert.equal(request.fetchImpl, fetchImpl);
|
||||||
|
assert.equal(request.signal, controller.signal);
|
||||||
|
assert.equal(request.box, query.box);
|
||||||
|
return [candidate];
|
||||||
|
},
|
||||||
|
thumbnailUrl: ({ product, day, width }) => `https://imagery.example/${product}/${day}/${width}`,
|
||||||
|
tileTemplate: (product, day) => `https://tiles.example/${product}/${day}/{z}/{x}/{y}`,
|
||||||
|
credit: 'Example imagery',
|
||||||
|
});
|
||||||
|
assert.deepEqual(await source.searchHls(query), [candidate]);
|
||||||
|
assert.equal(await (await source.getThumbnail(candidate, query.box, { signal: controller.signal })).text(), 'image');
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(calls[0][0], 'https://imagery.example/S30/2026-10-01/256');
|
||||||
|
assert.equal(calls[0][1].signal, controller.signal);
|
||||||
|
assert.equal(source.getTileTemplate(candidate.product, candidate.day), 'https://tiles.example/S30/2026-10-01/{z}/{x}/{y}');
|
||||||
|
assert.equal(source.credit, 'Example imagery');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancelled thumbnail acquisition does not return stale responses or retry', async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
let calls = 0;
|
||||||
|
const source = createRecentImagerySource({
|
||||||
|
thumbnailUrl: () => 'https://imagery.example/thumbnail',
|
||||||
|
fetchImpl: async () => { calls++; controller.abort(); return new Response('image'); },
|
||||||
|
});
|
||||||
|
await assert.rejects(source.getThumbnail({}, {}, { signal: controller.signal }), { name: 'AbortError' });
|
||||||
|
await assert.rejects(source.getThumbnail({}, {}, { signal: controller.signal }), { name: 'AbortError' });
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
});
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
* card on every render, so a read that counted would evict the visible cards
|
* card on every render, so a read that counted would evict the visible cards
|
||||||
* first). Eviction revokes the image only; what the probe proved is kept.
|
* first). Eviction revokes the image only; what the probe proved is kept.
|
||||||
*/
|
*/
|
||||||
import { thumbnailOrder, wvsSnapshotUrl } from './model.js';
|
import { thumbnailOrder } from './model.js';
|
||||||
|
import { createRecentImagerySource } from './source.js';
|
||||||
|
|
||||||
const EMPTY = Object.freeze({
|
const EMPTY = Object.freeze({
|
||||||
status: 'unknown',
|
status: 'unknown',
|
||||||
@@ -22,6 +23,7 @@ const EMPTY = Object.freeze({
|
|||||||
*/
|
*/
|
||||||
export function createThumbnailLoader({
|
export function createThumbnailLoader({
|
||||||
fetchImpl = globalThis.fetch?.bind(globalThis),
|
fetchImpl = globalThis.fetch?.bind(globalThis),
|
||||||
|
requestThumbnail = createRecentImagerySource({ fetchImpl }).getThumbnail,
|
||||||
maxInFlight = 3,
|
maxInFlight = 3,
|
||||||
maxDecoded = 16,
|
maxDecoded = 16,
|
||||||
createObjectUrl = (blob) => URL.createObjectURL(blob),
|
createObjectUrl = (blob) => URL.createObjectURL(blob),
|
||||||
@@ -90,14 +92,10 @@ export function createThumbnailLoader({
|
|||||||
let acquisitionTime = null;
|
let acquisitionTime = null;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
try {
|
try {
|
||||||
const url = wvsSnapshotUrl({
|
const response = await requestThumbnail(entry.candidate, entry.box, {
|
||||||
product: entry.candidate.product,
|
size,
|
||||||
day: entry.candidate.day,
|
signal: controller.signal,
|
||||||
box: entry.box,
|
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
});
|
});
|
||||||
const response = await fetchImpl(url, { signal: controller.signal });
|
|
||||||
if (controller.signal.aborted) {
|
if (controller.signal.aborted) {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
} else if (response?.ok) {
|
} else if (response?.ok) {
|
||||||
|
|||||||
@@ -315,3 +315,24 @@ test('a request queued behind an aborted fetch still starts once the abort settl
|
|||||||
tracked: 1,
|
tracked: 1,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('injected thumbnail acquisition owns requests while the loader releases object URLs', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const { loader, fetch, revoked } = fixture({
|
||||||
|
requestThumbnail: async (...args) => {
|
||||||
|
calls.push(args);
|
||||||
|
return response({ headers: { 'Data-Present': 'true' } });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const entry = candidate('S30', '2026-09-18');
|
||||||
|
loader.request(entry, BOX, 0);
|
||||||
|
await settle();
|
||||||
|
assert.equal(fetch.calls.length, 0);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(calls[0][0], entry);
|
||||||
|
assert.deepEqual(calls[0][1], BOX);
|
||||||
|
assert.ok(calls[0][2].signal instanceof AbortSignal);
|
||||||
|
assert.equal(loader.get(entry.key).status, 'present');
|
||||||
|
loader.destroy();
|
||||||
|
assert.deepEqual(revoked, ['blob:1']);
|
||||||
|
});
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { decodeCoverageTile } from './decode.js';
|
import { decodeCoverageTile } from './decode.js';
|
||||||
import { passesImageryFilter, resolveFilter } from './filter.js';
|
import { passesImageryFilter } from './filter.js';
|
||||||
import { isActive } from './state.js';
|
|
||||||
import { groundUnderCamera, viewFocus, visibleBbox } from './view.js';
|
import { groundUnderCamera, viewFocus, visibleBbox } from './view.js';
|
||||||
import { coverageZoomForHeight, tilesForBbox } from './tileMath.js';
|
import { coverageZoomForHeight, tilesForBbox } from './tileMath.js';
|
||||||
import {
|
import {
|
||||||
@@ -96,7 +95,7 @@ export function createCoverage({ state, source }) {
|
|||||||
|
|
||||||
/** The imagery filter, resolved now: "since N days" moves with the clock. */
|
/** The imagery filter, resolved now: "since N days" moves with the clock. */
|
||||||
function filter() {
|
function filter() {
|
||||||
return resolveFilter(state.filter);
|
return state.getFilter();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -320,7 +319,7 @@ export function createCoverage({ state, source }) {
|
|||||||
/** Recompute the tile set for the current camera and reconcile primitives. */
|
/** Recompute the tile set for the current camera and reconcile primitives. */
|
||||||
function refresh() {
|
function refresh() {
|
||||||
const viewer = state.viewer;
|
const viewer = state.viewer;
|
||||||
if (!viewer || !isActive(state) || state.coverage.blocked) return;
|
if (!viewer || !state.isActive() || state.coverage.blocked) return;
|
||||||
const { zoom, tiles, hint } = wantedTiles(viewer);
|
const { zoom, tiles, hint } = wantedTiles(viewer);
|
||||||
if (hint) {
|
if (hint) {
|
||||||
state.coverage.hint = hint;
|
state.coverage.hint = hint;
|
||||||
@@ -410,7 +409,7 @@ export function createCoverage({ state, source }) {
|
|||||||
// Waiting for the key status is loading too, not an empty view.
|
// Waiting for the key status is loading too, not an empty view.
|
||||||
loading:
|
loading:
|
||||||
state.coverage.pending.size > 0 ||
|
state.coverage.pending.size > 0 ||
|
||||||
(blocked === 'status' && isActive(state)),
|
(blocked === 'status' && state.isActive()),
|
||||||
hint: state.coverage.hint,
|
hint: state.coverage.hint,
|
||||||
keyRequired: blocked === 'no-key' || blocked === 'rejected',
|
keyRequired: blocked === 'no-key' || blocked === 'rejected',
|
||||||
keyRejected: blocked === 'rejected',
|
keyRejected: blocked === 'rejected',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { createCoverage, ZOOM_IN_HINT } from './coverage.js';
|
import { createCoverage, ZOOM_IN_HINT } from './coverage.js';
|
||||||
import { freshCoverage } from './state.js';
|
import { freshCoverage } from './state.js';
|
||||||
|
import { resolveFilter } from './filter.js';
|
||||||
import { encodeCoverageTile } from './coverageFixture.mjs';
|
import { encodeCoverageTile } from './coverageFixture.mjs';
|
||||||
import { rayCamera } from '../../testSupport/streetLevelFakes.mjs';
|
import { rayCamera } from '../../testSupport/streetLevelFakes.mjs';
|
||||||
import { COVERAGE_MAX_SEQUENCES, COVERAGE_MAX_TILES } from './policy.js';
|
import { COVERAGE_MAX_SEQUENCES, COVERAGE_MAX_TILES } from './policy.js';
|
||||||
@@ -93,6 +94,8 @@ function setup() {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
providerOn: true,
|
providerOn: true,
|
||||||
filter: { pano: 'all', sinceDays: 0 },
|
filter: { pano: 'all', sinceDays: 0 },
|
||||||
|
getFilter: () => resolveFilter(state.filter),
|
||||||
|
isActive: () => state.enabled && state.providerOn,
|
||||||
notify() {},
|
notify() {},
|
||||||
coverage: {
|
coverage: {
|
||||||
...freshCoverage(),
|
...freshCoverage(),
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import * as Cesium from 'cesium';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One static Cesium credit per active provider: CC BY-SA imagery needs
|
||||||
|
* visible attribution that goes away with the imagery.
|
||||||
|
*/
|
||||||
|
export function createCredits() {
|
||||||
|
/** @type {Map<string, object>} provider id → Cesium.Credit */
|
||||||
|
const shown = new Map();
|
||||||
|
|
||||||
|
function show(viewer, def) {
|
||||||
|
if (shown.has(def.id) || !viewer?.creditDisplay) return;
|
||||||
|
try {
|
||||||
|
const credit = new Cesium.Credit(def.credit.html, true);
|
||||||
|
viewer.creditDisplay.addStaticCredit(credit);
|
||||||
|
shown.set(def.id, credit);
|
||||||
|
} catch {
|
||||||
|
/* credit display unavailable */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide(viewer, def) {
|
||||||
|
const credit = shown.get(def.id);
|
||||||
|
if (!credit) return;
|
||||||
|
try {
|
||||||
|
viewer?.creditDisplay?.removeStaticCredit?.(credit);
|
||||||
|
} catch {
|
||||||
|
/* credit display already torn down */
|
||||||
|
}
|
||||||
|
shown.delete(def.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideAll(viewer) {
|
||||||
|
for (const id of [...shown.keys()]) hide(viewer, { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { show, hide, hideAll };
|
||||||
|
}
|
||||||
+186
-134
@@ -1,61 +1,55 @@
|
|||||||
import * as Cesium from 'cesium';
|
|
||||||
import { createState } from './state.js';
|
import { createState } from './state.js';
|
||||||
import { createMarker } from './marker.js';
|
import { createMarker } from './marker.js';
|
||||||
import { createCameraFraming } from './cameraFraming.js';
|
import { createCameraFraming } from './cameraFraming.js';
|
||||||
|
import { createCredits } from './credits.js';
|
||||||
|
import { createPickRouter } from './pickRouter.js';
|
||||||
import { createSelection } from './selection.js';
|
import { createSelection } from './selection.js';
|
||||||
import { createViewerHost } from './viewerHost.js';
|
import { createViewerHost } from './viewerHost.js';
|
||||||
import { normalizeFilter, sameFilter } from './filter.js';
|
import { requiresKeyIdFor, validateProviders } from './registry.js';
|
||||||
|
import { normalizeFilter, resolveFilter, sameFilter } from './filter.js';
|
||||||
|
import { decodeParams, encodeParams } from './params.js';
|
||||||
|
import { composeUIState, summarizeCoverage } from './uiState.js';
|
||||||
import {
|
import {
|
||||||
keyStatusLabel,
|
keyStatusLabel,
|
||||||
MAPILLARY_CREDIT_HTML,
|
POSITION_PICK_ID,
|
||||||
MAPILLARY_KEY_ID,
|
|
||||||
STREET_LEVEL_LAYER_ID,
|
STREET_LEVEL_LAYER_ID,
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
import { createCoverage } from './coverage.js';
|
|
||||||
import { createSequences } from './sequences.js';
|
|
||||||
import { createMapillaryViewer } from './viewer.js';
|
|
||||||
|
|
||||||
export { STREET_LEVEL_LAYER_ID } from './policy.js';
|
export { STREET_LEVEL_LAYER_ID } from './policy.js';
|
||||||
|
|
||||||
/** Longest wait for the server's key status; past it, the bundle's token decides. */
|
/** Longest wait for a provider's key status; past it, the provider decides. */
|
||||||
const KEY_STATUS_TIMEOUT_MS = 10_000;
|
const KEY_STATUS_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
/** Coverage as the panel sees it while the `mapillary` switch is off. */
|
|
||||||
const COVERAGE_OFF = Object.freeze({
|
|
||||||
count: 0,
|
|
||||||
loading: false,
|
|
||||||
hint: '',
|
|
||||||
error: null,
|
|
||||||
keyRequired: false,
|
|
||||||
keyRejected: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Street Level layer: Mapillary coverage lines at street zoom, a selected
|
* The Street Level layer over imagery providers (contract in registry.js).
|
||||||
* sequence's image cones, and the photo viewer in the panel.
|
* The core owns what they share: the filter and switches, the photo viewer
|
||||||
* @param {{source: object, services?: object, photoViewer?: object}} options
|
* host, the position marker and the click handler. Providers own coverage,
|
||||||
* `source` is source.js or a stand-in; `photoViewer` stands in for the
|
* sequences and their viewer.
|
||||||
* MapillaryJS adapter (viewer.js).
|
* @param {{providers: Array<import('./registry.js').StreetLevelProvider>, services?: object}} options
|
||||||
*/
|
*/
|
||||||
export function createStreetLevelLayer({
|
export function createStreetLevelLayer({
|
||||||
source,
|
providers: definitions,
|
||||||
services = {},
|
services = {},
|
||||||
photoViewer = null,
|
|
||||||
}) {
|
}) {
|
||||||
|
const defs = validateProviders(definitions);
|
||||||
const state = createState({ services });
|
const state = createState({ services });
|
||||||
const parts = {};
|
const parts = {};
|
||||||
const context = { state, parts };
|
const context = { state, parts };
|
||||||
|
parts.credits = createCredits();
|
||||||
parts.marker = createMarker(context);
|
parts.marker = createMarker(context);
|
||||||
parts.framing = createCameraFraming(context);
|
parts.framing = createCameraFraming(context);
|
||||||
parts.coverage = createCoverage({ state, source });
|
parts.router = createPickRouter(() => state.providers.values(), {
|
||||||
parts.sequences = createSequences({ state, source, parts });
|
positionId: POSITION_PICK_ID,
|
||||||
parts.viewerHost = createViewerHost({
|
|
||||||
state,
|
|
||||||
parts,
|
|
||||||
adapter:
|
|
||||||
photoViewer || createMapillaryViewer({ source, render: services.render }),
|
|
||||||
});
|
});
|
||||||
parts.openImage = openImage;
|
parts.viewerHost = createViewerHost(context);
|
||||||
|
parts.hasSelectedSequence = () =>
|
||||||
|
[...state.providers.values()].some(
|
||||||
|
(entry) => entry.instance.sequenceStats?.()?.selectedId,
|
||||||
|
);
|
||||||
|
parts.clearSequences = () => {
|
||||||
|
for (const entry of state.providers.values())
|
||||||
|
entry.instance.clearSequence?.();
|
||||||
|
};
|
||||||
parts.selection = createSelection(context);
|
parts.selection = createSelection(context);
|
||||||
|
|
||||||
let notifyQueued = false;
|
let notifyQueued = false;
|
||||||
@@ -76,127 +70,184 @@ export function createStreetLevelLayer({
|
|||||||
}
|
}
|
||||||
state.notify = notify;
|
state.notify = notify;
|
||||||
|
|
||||||
/** The key-status request in flight; overlapping checks share it. */
|
function providerContext(entry) {
|
||||||
let keyCheck = null;
|
return Object.freeze({
|
||||||
|
services: state.services,
|
||||||
/** Coverage waits for the key status: no tile requests without a key. */
|
/** Resolved against now, so "since N days" keeps up with the clock. */
|
||||||
function checkKey() {
|
getFilter: () => resolveFilter(state.filter),
|
||||||
keyCheck ||= (async () => {
|
/** The layer and this provider's switch are both on. */
|
||||||
let configured;
|
isActive: () => state.enabled && entry.on,
|
||||||
try {
|
notify,
|
||||||
// A hung server must not hold LOADING, or the next check, forever.
|
actions: {
|
||||||
const signal = AbortSignal.timeout(KEY_STATUS_TIMEOUT_MS);
|
openImage: (imageId) => openImage(entry.def.id, imageId),
|
||||||
configured = (await source.getStatus({ signal }))?.configured === true;
|
},
|
||||||
} catch {
|
|
||||||
configured = source.hasToken?.() === true;
|
|
||||||
}
|
|
||||||
parts.coverage.setKeyStatus(configured);
|
|
||||||
})().finally(() => {
|
|
||||||
keyCheck = null;
|
|
||||||
});
|
});
|
||||||
return keyCheck;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The on-globe credit while the layer draws: CC BY-SA imagery needs it. */
|
for (const def of defs) {
|
||||||
let credit = null;
|
const entry = {
|
||||||
function showCredit(viewer) {
|
def,
|
||||||
if (credit || !viewer?.creditDisplay) return;
|
instance: null,
|
||||||
credit = new Cesium.Credit(MAPILLARY_CREDIT_HTML, true);
|
on: true,
|
||||||
viewer.creditDisplay.addStaticCredit(credit);
|
status: null,
|
||||||
}
|
/** The status request in flight; overlapping checks share it. */
|
||||||
function hideCredit(viewer) {
|
statusCheck: null,
|
||||||
if (!credit) return;
|
};
|
||||||
viewer?.creditDisplay?.removeStaticCredit?.(credit);
|
entry.instance = def.create(providerContext(entry));
|
||||||
credit = null;
|
state.providers.set(def.id, entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
function startDrawing() {
|
const activeEntries = () =>
|
||||||
const viewer = state.viewer;
|
[...state.providers.values()].filter((entry) => entry.on);
|
||||||
if (!viewer) return;
|
|
||||||
parts.sequences.setVisible(true);
|
/** A provider's key status. A hung server must not hold LOADING, or the next check, forever. */
|
||||||
parts.coverage.attach(viewer);
|
function checkStatus(entry) {
|
||||||
showCredit(viewer);
|
entry.statusCheck ||= (async () => {
|
||||||
|
try {
|
||||||
|
entry.status = await entry.instance.status({
|
||||||
|
signal: AbortSignal.timeout(KEY_STATUS_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
entry.status = null;
|
||||||
|
}
|
||||||
|
notify();
|
||||||
|
})().finally(() => {
|
||||||
|
entry.statusCheck = null;
|
||||||
|
});
|
||||||
|
return entry.statusCheck;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activate(entry) {
|
||||||
|
if (!state.viewer) return;
|
||||||
|
entry.instance.activate(state.viewer);
|
||||||
|
parts.credits.show(state.viewer, entry.def);
|
||||||
// Without a known key, a switch-on asks again: the server may be back,
|
// Without a known key, a switch-on asks again: the server may be back,
|
||||||
// or have a key now.
|
// or have a key now.
|
||||||
if (['status', 'no-key'].includes(state.coverage.blocked)) checkKey();
|
if (entry.status?.configured !== true) checkStatus(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Close the photo, clear lines and cones, forget refusals. */
|
function deactivate(entry) {
|
||||||
function stopDrawing() {
|
parts.viewerHost.unmount(entry.def.id);
|
||||||
parts.viewerHost.unmount();
|
entry.instance.deactivate();
|
||||||
parts.coverage.detach();
|
parts.credits.hide(state.viewer, entry.def);
|
||||||
parts.coverage.clear();
|
|
||||||
parts.coverage.unblock();
|
|
||||||
parts.sequences.clearSelection();
|
|
||||||
parts.sequences.setVisible(false);
|
|
||||||
hideCredit(state.viewer);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The share link's `mapillary` switch: off, the layer draws nothing. */
|
function openImage(providerId, imageId) {
|
||||||
function setProviderOn(on) {
|
return parts.viewerHost.open(providerId, imageId);
|
||||||
if (state.providerOn === on) return;
|
}
|
||||||
state.providerOn = on;
|
|
||||||
|
function setProviderEnabled(providerId, on) {
|
||||||
|
const entry = state.providers.get(providerId);
|
||||||
|
if (!entry) return false;
|
||||||
|
const next = on !== false;
|
||||||
|
if (entry.on === next) return true;
|
||||||
|
entry.on = next;
|
||||||
if (state.enabled) {
|
if (state.enabled) {
|
||||||
if (on) startDrawing();
|
if (next) activate(entry);
|
||||||
else stopDrawing();
|
else deactivate(entry);
|
||||||
}
|
}
|
||||||
notify();
|
notify();
|
||||||
}
|
return true;
|
||||||
|
|
||||||
function openImage(imageId) {
|
|
||||||
return parts.viewerHost.open(imageId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setCoverageFilter(next) {
|
function setCoverageFilter(next) {
|
||||||
const filter = normalizeFilter(next, state.filter);
|
const filter = normalizeFilter(next, state.filter);
|
||||||
if (sameFilter(filter, state.filter)) return;
|
if (sameFilter(filter, state.filter)) return;
|
||||||
state.filter = filter;
|
state.filter = filter;
|
||||||
parts.coverage.rebuild();
|
const resolved = resolveFilter(filter);
|
||||||
parts.sequences.rerender();
|
for (const entry of state.providers.values())
|
||||||
|
entry.instance.setFilter(resolved);
|
||||||
notify();
|
notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
function coverageStats() {
|
function providerSnapshots() {
|
||||||
return state.providerOn ? parts.coverage.stats() : COVERAGE_OFF;
|
return [...state.providers.values()].map((entry) => {
|
||||||
|
const stats = entry.instance.coverageStats();
|
||||||
|
return {
|
||||||
|
id: entry.def.id,
|
||||||
|
name: entry.def.name,
|
||||||
|
label: entry.def.label,
|
||||||
|
on: entry.on,
|
||||||
|
configured: entry.status ? entry.status.configured === true : null,
|
||||||
|
// A rejected key gates the provider exactly like a missing one.
|
||||||
|
keyRequired: stats.keyRequired === true || stats.keyRejected === true,
|
||||||
|
keyRejected: stats.keyRejected === true,
|
||||||
|
requiresKeyId: entry.def.requiresKeyId || null,
|
||||||
|
loading: stats.loading === true,
|
||||||
|
count: stats.count || 0,
|
||||||
|
hint: stats.hint || '',
|
||||||
|
error: stats.error || null,
|
||||||
|
color: entry.def.colors.coverage,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The selected sequence; the provider showing the open image answers first. */
|
||||||
|
function sequenceSnapshot() {
|
||||||
|
const owner = state.providers.get(state.street.providerId);
|
||||||
|
const candidates = owner
|
||||||
|
? [owner, ...[...state.providers.values()].filter((e) => e !== owner)]
|
||||||
|
: [...state.providers.values()];
|
||||||
|
for (const entry of candidates) {
|
||||||
|
const stats = entry.instance.sequenceStats?.();
|
||||||
|
if (stats?.selectedId || stats?.loading || stats?.error)
|
||||||
|
return {
|
||||||
|
providerId: entry.def.id,
|
||||||
|
selectedId: stats.selectedId || null,
|
||||||
|
images: stats.images || 0,
|
||||||
|
loading: stats.loading === true,
|
||||||
|
error: stats.error || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
providerId: null,
|
||||||
|
selectedId: null,
|
||||||
|
images: 0,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUIState() {
|
function getUIState() {
|
||||||
const { host, ...street } = state.street;
|
const { host, ...street } = state.street;
|
||||||
const { keyRequired, keyRejected, ...coverage } = coverageStats();
|
return composeUIState({
|
||||||
return {
|
|
||||||
enabled: state.enabled,
|
enabled: state.enabled,
|
||||||
providerOn: state.providerOn,
|
filter: state.filter,
|
||||||
// A rejected key gates the layer exactly like a missing one.
|
providers: providerSnapshots(),
|
||||||
keyRequired,
|
|
||||||
keyRejected,
|
|
||||||
filter: { ...state.filter },
|
|
||||||
coverage,
|
|
||||||
sequence: {
|
|
||||||
selectedId: state.sequence.selectedId,
|
|
||||||
images: state.sequence.images.length,
|
|
||||||
loading: state.sequence.loading,
|
|
||||||
error: state.sequence.error,
|
|
||||||
},
|
|
||||||
street,
|
street,
|
||||||
};
|
sequence: sequenceSnapshot(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const layer = {
|
const layer = {
|
||||||
id: STREET_LEVEL_LAYER_ID,
|
id: STREET_LEVEL_LAYER_ID,
|
||||||
name: 'Street Level',
|
name: 'Street Level',
|
||||||
icon: '📷',
|
icon: '📷',
|
||||||
source: 'Mapillary',
|
source: defs.map((def) => def.name).join(' · '),
|
||||||
updateInterval: 0,
|
updateInterval: 0,
|
||||||
statsRefreshInterval: 1000,
|
statsRefreshInterval: 1000,
|
||||||
requiresKeyId: MAPILLARY_KEY_ID,
|
requiresKeyId: requiresKeyIdFor(defs),
|
||||||
|
/** Registered providers, in chip order. */
|
||||||
|
providerIds: defs.map((def) => def.id),
|
||||||
|
getSourceAvailability() {
|
||||||
|
const available = defs.length > 0;
|
||||||
|
return {
|
||||||
|
available,
|
||||||
|
source: STREET_LEVEL_LAYER_ID,
|
||||||
|
reason: available
|
||||||
|
? null
|
||||||
|
: 'No street-level imagery providers configured',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
init(viewer) {
|
init(viewer) {
|
||||||
state.viewer = viewer;
|
state.viewer = viewer;
|
||||||
parts.marker.ensure(viewer);
|
parts.marker.ensure(viewer);
|
||||||
parts.marker.setVisible(false);
|
parts.marker.setVisible(false);
|
||||||
parts.sequences.ensureCollections(viewer);
|
for (const entry of state.providers.values()) {
|
||||||
parts.sequences.setVisible(false);
|
entry.instance.init(viewer);
|
||||||
checkKey();
|
checkStatus(entry);
|
||||||
|
}
|
||||||
console.log('[Data:StreetLevel] Initialized');
|
console.log('[Data:StreetLevel] Initialized');
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -205,13 +256,15 @@ export function createStreetLevelLayer({
|
|||||||
state.viewer = viewer;
|
state.viewer = viewer;
|
||||||
parts.marker.setVisible(true);
|
parts.marker.setVisible(true);
|
||||||
parts.selection.install(viewer);
|
parts.selection.install(viewer);
|
||||||
if (state.providerOn) startDrawing();
|
for (const entry of activeEntries()) activate(entry);
|
||||||
notify();
|
notify();
|
||||||
},
|
},
|
||||||
|
|
||||||
disable() {
|
disable() {
|
||||||
state.enabled = false;
|
state.enabled = false;
|
||||||
stopDrawing();
|
parts.viewerHost.unmount();
|
||||||
|
for (const entry of state.providers.values()) entry.instance.deactivate();
|
||||||
|
parts.credits.hideAll(state.viewer);
|
||||||
parts.selection.uninstall();
|
parts.selection.uninstall();
|
||||||
parts.marker.setVisible(false);
|
parts.marker.setVisible(false);
|
||||||
notify();
|
notify();
|
||||||
@@ -223,7 +276,8 @@ export function createStreetLevelLayer({
|
|||||||
|
|
||||||
destroy(viewer = state.viewer) {
|
destroy(viewer = state.viewer) {
|
||||||
layer.disable();
|
layer.disable();
|
||||||
parts.sequences.destroy(viewer);
|
for (const entry of state.providers.values())
|
||||||
|
entry.instance.destroy(viewer);
|
||||||
parts.marker.destroy(viewer);
|
parts.marker.destroy(viewer);
|
||||||
parts.framing.attachNavigation(null);
|
parts.framing.attachNavigation(null);
|
||||||
state.listeners.clear();
|
state.listeners.clear();
|
||||||
@@ -231,7 +285,8 @@ export function createStreetLevelLayer({
|
|||||||
},
|
},
|
||||||
|
|
||||||
getStats() {
|
getStats() {
|
||||||
const coverage = coverageStats();
|
// The lifecycle polls this every second: summarise, don't snapshot.
|
||||||
|
const coverage = summarizeCoverage(providerSnapshots());
|
||||||
const keyLabel = keyStatusLabel(coverage);
|
const keyLabel = keyStatusLabel(coverage);
|
||||||
let loadingLabel = '';
|
let loadingLabel = '';
|
||||||
if (keyLabel) loadingLabel = keyLabel;
|
if (keyLabel) loadingLabel = keyLabel;
|
||||||
@@ -252,26 +307,22 @@ export function createStreetLevelLayer({
|
|||||||
parts.framing.attachNavigation(navigation);
|
parts.framing.attachNavigation(navigation);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/** Share-link and stored state: provider switches plus the filter. */
|
||||||
* Share-link and stored state, in the codec's keys (src/data/layerState.js):
|
|
||||||
* the `mapillary` switch plus the filter.
|
|
||||||
*/
|
|
||||||
getParams() {
|
getParams() {
|
||||||
return {
|
return encodeParams({
|
||||||
mapillary: state.providerOn,
|
providers: [...state.providers].map(([id, entry]) => [id, entry.on]),
|
||||||
pano: state.filter.pano,
|
filter: state.filter,
|
||||||
sinceDays: state.filter.sinceDays,
|
});
|
||||||
};
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Unknown keys and malformed values are ignored, so any link applies. */
|
/** Unknown providers and malformed values are ignored, so any link applies. */
|
||||||
setParams(params = {}) {
|
setParams(params = {}) {
|
||||||
const input = params && typeof params === 'object' ? params : {};
|
const decoded = decodeParams(params, {
|
||||||
if (typeof input.mapillary === 'boolean') setProviderOn(input.mapillary);
|
providerIds: state.providers.keys(),
|
||||||
const next = {};
|
filter: state.filter,
|
||||||
if ('pano' in input) next.pano = input.pano;
|
});
|
||||||
if ('sinceDays' in input) next.sinceDays = input.sinceDays;
|
for (const [id, on] of decoded.providers) setProviderEnabled(id, on);
|
||||||
setCoverageFilter(next);
|
setCoverageFilter(decoded.filter);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -282,15 +333,16 @@ export function createStreetLevelLayer({
|
|||||||
return () => state.listeners.delete(listener);
|
return () => state.listeners.delete(listener);
|
||||||
},
|
},
|
||||||
getUIState,
|
getUIState,
|
||||||
/** The DOM element the photo viewer renders into. */
|
/** The DOM element provider viewers render into. */
|
||||||
attachViewerHost(element) {
|
attachViewerHost(element) {
|
||||||
parts.viewerHost.attach(element);
|
parts.viewerHost.attach(element);
|
||||||
},
|
},
|
||||||
|
setProviderEnabled,
|
||||||
openImage,
|
openImage,
|
||||||
/** Close the image and deselect its sequence on the map. */
|
/** Close the image and deselect its sequence on the map. */
|
||||||
closeViewer() {
|
closeViewer() {
|
||||||
parts.viewerHost.close();
|
parts.viewerHost.close();
|
||||||
parts.sequences.clearSelection();
|
parts.clearSequences();
|
||||||
},
|
},
|
||||||
setViewerRenderMode: (mode) => parts.viewerHost.setRenderMode(mode),
|
setViewerRenderMode: (mode) => parts.viewerHost.setRenderMode(mode),
|
||||||
resizeViewer: () => parts.viewerHost.resize(),
|
resizeViewer: () => parts.viewerHost.resize(),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { createStreetLevelLayer } from './index.js';
|
import { createStreetLevelLayer } from './index.js';
|
||||||
|
import { createMapillaryProvider } from './mapillaryProvider.js';
|
||||||
import { MAPILLARY_CREDIT_HTML } from './policy.js';
|
import { MAPILLARY_CREDIT_HTML } from './policy.js';
|
||||||
import {
|
import {
|
||||||
fakeCesiumViewer,
|
fakeCesiumViewer,
|
||||||
@@ -18,6 +19,7 @@ async function startLayer(
|
|||||||
{
|
{
|
||||||
source = fakeMapillarySource(),
|
source = fakeMapillarySource(),
|
||||||
photoViewer = fakePhotoViewer(),
|
photoViewer = fakePhotoViewer(),
|
||||||
|
providers = [createMapillaryProvider({ source, photoViewer })],
|
||||||
view = false,
|
view = false,
|
||||||
enable = true,
|
enable = true,
|
||||||
} = {},
|
} = {},
|
||||||
@@ -32,7 +34,7 @@ async function startLayer(
|
|||||||
async () => {},
|
async () => {},
|
||||||
);
|
);
|
||||||
const viewer = fakeCesiumViewer({ view });
|
const viewer = fakeCesiumViewer({ view });
|
||||||
const layer = createStreetLevelLayer({ source, photoViewer });
|
const layer = createStreetLevelLayer({ providers });
|
||||||
layer.init(viewer);
|
layer.init(viewer);
|
||||||
if (enable) layer.enable(viewer);
|
if (enable) layer.enable(viewer);
|
||||||
t.after(() => {
|
t.after(() => {
|
||||||
@@ -181,13 +183,13 @@ test('a rejected key gates the layer as KEY REJECTED until it goes off', async (
|
|||||||
test('switching Mapillary off clears coverage and closes the image it shows', async (t) => {
|
test('switching Mapillary off clears coverage and closes the image it shows', async (t) => {
|
||||||
const { layer, photoViewer } = await startLayer(t);
|
const { layer, photoViewer } = await startLayer(t);
|
||||||
layer.attachViewerHost({});
|
layer.attachViewerHost({});
|
||||||
assert.equal(await layer.openImage('img1'), true);
|
assert.equal(await layer.openImage('mapillary', 'img1'), true);
|
||||||
assert.deepEqual(photoViewer.calls.open, ['img1']);
|
assert.deepEqual(photoViewer.calls.open, ['img1']);
|
||||||
assert.equal(layer.getUIState().street.open, true);
|
assert.equal(layer.getUIState().street.open, true);
|
||||||
layer.setParams({ mapillary: false });
|
layer.setParams({ mapillary: false });
|
||||||
assert.equal(photoViewer.calls.unmount, 1, 'its viewer is released');
|
assert.equal(photoViewer.calls.unmount, 1, 'its viewer is released');
|
||||||
assert.equal(layer.getUIState().street.open, false);
|
assert.equal(layer.getUIState().street.open, false);
|
||||||
assert.equal(layer.getUIState().providerOn, false);
|
assert.equal(layer.getUIState().providers[0].on, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a sequence that fails to load reports it apart from the photo, and the error goes with it', async (t) => {
|
test('a sequence that fails to load reports it apart from the photo, and the error goes with it', async (t) => {
|
||||||
@@ -202,7 +204,7 @@ test('a sequence that fails to load reports it apart from the photo, and the err
|
|||||||
});
|
});
|
||||||
const { layer } = await startLayer(t, { source, photoViewer });
|
const { layer } = await startLayer(t, { source, photoViewer });
|
||||||
layer.attachViewerHost({});
|
layer.attachViewerHost({});
|
||||||
assert.equal(await layer.openImage('img1'), true);
|
assert.equal(await layer.openImage('mapillary', 'img1'), true);
|
||||||
await settle();
|
await settle();
|
||||||
let ui = layer.getUIState();
|
let ui = layer.getUIState();
|
||||||
assert.equal(ui.sequence.error, 'Sequence images unavailable');
|
assert.equal(ui.sequence.error, 'Sequence images unavailable');
|
||||||
@@ -217,17 +219,17 @@ test('closing the photo, switching the layer off or destroying it stops the fram
|
|||||||
photoViewer: posingViewer(),
|
photoViewer: posingViewer(),
|
||||||
});
|
});
|
||||||
layer.attachViewerHost({});
|
layer.attachViewerHost({});
|
||||||
assert.equal(await layer.openImage('img1'), true);
|
assert.equal(await layer.openImage('mapillary', 'img1'), true);
|
||||||
assert.equal(viewer.flights.started, 1, 'the photo is framed');
|
assert.equal(viewer.flights.started, 1, 'the photo is framed');
|
||||||
layer.closeViewer();
|
layer.closeViewer();
|
||||||
assert.equal(viewer.flights.cancelled, 1, 'closed');
|
assert.equal(viewer.flights.cancelled, 1, 'closed');
|
||||||
|
|
||||||
await layer.openImage('img2');
|
await layer.openImage('mapillary', 'img2');
|
||||||
layer.disable();
|
layer.disable();
|
||||||
assert.equal(viewer.flights.cancelled, 2, 'layer off');
|
assert.equal(viewer.flights.cancelled, 2, 'layer off');
|
||||||
|
|
||||||
layer.enable(viewer);
|
layer.enable(viewer);
|
||||||
await layer.openImage('img3');
|
await layer.openImage('mapillary', 'img3');
|
||||||
layer.destroy();
|
layer.destroy();
|
||||||
assert.equal(viewer.flights.cancelled, 3, 'destroyed');
|
assert.equal(viewer.flights.cancelled, 3, 'destroyed');
|
||||||
});
|
});
|
||||||
@@ -264,7 +266,7 @@ test('setParams ignores unknown keys and malformed values', async (t) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('switching Mapillary on while the layer is off draws nothing (M01)', async (t) => {
|
test('switching Mapillary on while the layer is off draws nothing', async (t) => {
|
||||||
const { layer, viewer, source, photoViewer } = await startLayer(t, {
|
const { layer, viewer, source, photoViewer } = await startLayer(t, {
|
||||||
view: true,
|
view: true,
|
||||||
enable: false,
|
enable: false,
|
||||||
@@ -276,10 +278,24 @@ test('switching Mapillary on while the layer is off draws nothing (M01)', async
|
|||||||
assert.equal(source.calls.tiles.length, 0, 'no coverage drawn');
|
assert.equal(source.calls.tiles.length, 0, 'no coverage drawn');
|
||||||
assert.deepEqual(viewer.credits, [], 'no credit shown');
|
assert.deepEqual(viewer.credits, [], 'no credit shown');
|
||||||
assert.equal(photoViewer.calls.mount, 0, 'no viewer stood up');
|
assert.equal(photoViewer.calls.mount, 0, 'no viewer stood up');
|
||||||
assert.equal(layer.getUIState().providerOn, true, 'the switch is kept');
|
assert.equal(layer.getUIState().providers[0].on, true, 'the switch is kept');
|
||||||
// Enabling the layer is what draws it.
|
// Enabling the layer is what draws it.
|
||||||
layer.enable(viewer);
|
layer.enable(viewer);
|
||||||
await settle();
|
await settle();
|
||||||
assert.ok(source.calls.tiles.length > 0);
|
assert.ok(source.calls.tiles.length > 0);
|
||||||
assert.equal(viewer.credits.length, 1);
|
assert.equal(viewer.credits.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an empty provider composition enables and tears down without acquiring imagery', async (t) => {
|
||||||
|
const { layer, viewer } = await startLayer(t, { providers: [] });
|
||||||
|
assert.deepEqual(layer.providerIds, []);
|
||||||
|
assert.deepEqual(layer.getUIState().providers, []);
|
||||||
|
assert.equal(layer.getUIState().keyRequired, false);
|
||||||
|
assert.match(
|
||||||
|
layer.getUIState().coverage.hint,
|
||||||
|
/No street-level imagery providers/,
|
||||||
|
);
|
||||||
|
layer.disable();
|
||||||
|
layer.enable(viewer);
|
||||||
|
assert.deepEqual(layer.getUIState().providers, []);
|
||||||
|
});
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { freshCoverage } from './state.js';
|
||||||
|
import { createCoverage, sequenceIdFromPick } from './coverage.js';
|
||||||
|
import { createSequences } from './sequences.js';
|
||||||
|
import { createMapillaryViewer } from './viewer.js';
|
||||||
|
import {
|
||||||
|
COLORS,
|
||||||
|
MAPILLARY_CREDIT_HTML,
|
||||||
|
MAPILLARY_KEY_ID,
|
||||||
|
MAPILLARY_LABEL,
|
||||||
|
MAPILLARY_NAME,
|
||||||
|
MAPILLARY_PROVIDER_ID,
|
||||||
|
MAPILLARY_SOURCE_METHODS,
|
||||||
|
PICK_PREFIX,
|
||||||
|
} from './policy.js';
|
||||||
|
|
||||||
|
/** Per-provider mutable state; coverage and sequences share it. */
|
||||||
|
function createProviderState(context) {
|
||||||
|
return {
|
||||||
|
services: context.services,
|
||||||
|
getFilter: context.getFilter,
|
||||||
|
isActive: context.isActive,
|
||||||
|
notify: context.notify,
|
||||||
|
viewer: null,
|
||||||
|
coverage: freshCoverage(),
|
||||||
|
sequence: {
|
||||||
|
selectedId: null,
|
||||||
|
images: [],
|
||||||
|
collection: null,
|
||||||
|
loading: false,
|
||||||
|
/** Why the selected sequence's images could not load, until it goes. */
|
||||||
|
error: null,
|
||||||
|
abort: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Mapillary provider: coverage lines from vector tiles, image cones from
|
||||||
|
* the Graph API and the MapillaryJS viewer.
|
||||||
|
* @param {{source: object, photoViewer?: object}} options `source` is
|
||||||
|
* source.js or a stand-in; `photoViewer` stands in for the viewer adapter.
|
||||||
|
* @returns {import('./registry.js').StreetLevelProvider}
|
||||||
|
*/
|
||||||
|
export function createMapillaryProvider({ source, photoViewer = null }) {
|
||||||
|
if (
|
||||||
|
!MAPILLARY_SOURCE_METHODS.every(
|
||||||
|
(method) => typeof source?.[method] === 'function',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new TypeError('A Mapillary source is required');
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
id: MAPILLARY_PROVIDER_ID,
|
||||||
|
name: MAPILLARY_NAME,
|
||||||
|
label: MAPILLARY_LABEL,
|
||||||
|
requiresKeyId: MAPILLARY_KEY_ID,
|
||||||
|
pickPrefix: PICK_PREFIX.root,
|
||||||
|
colors: COLORS,
|
||||||
|
credit: Object.freeze({ html: MAPILLARY_CREDIT_HTML }),
|
||||||
|
|
||||||
|
create(context) {
|
||||||
|
const state = createProviderState(context);
|
||||||
|
const parts = {};
|
||||||
|
parts.coverage = createCoverage({ state, source });
|
||||||
|
parts.sequences = createSequences({ state, source, parts });
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** Coverage waits for the answer: no tile requests without a key. */
|
||||||
|
async status({ signal } = {}) {
|
||||||
|
let configured;
|
||||||
|
try {
|
||||||
|
configured =
|
||||||
|
(await source.getStatus({ signal }))?.configured === true;
|
||||||
|
} catch {
|
||||||
|
configured = source.hasToken?.() === true;
|
||||||
|
}
|
||||||
|
parts.coverage.setKeyStatus(configured);
|
||||||
|
return { configured };
|
||||||
|
},
|
||||||
|
|
||||||
|
init(viewer) {
|
||||||
|
state.viewer = viewer;
|
||||||
|
parts.sequences.ensureCollections(viewer);
|
||||||
|
parts.sequences.setVisible(false);
|
||||||
|
},
|
||||||
|
|
||||||
|
activate(viewer) {
|
||||||
|
state.viewer = viewer;
|
||||||
|
parts.sequences.setVisible(true);
|
||||||
|
parts.coverage.attach(viewer);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Clear lines and cones, forget refusals. */
|
||||||
|
deactivate() {
|
||||||
|
parts.coverage.detach();
|
||||||
|
parts.coverage.clear();
|
||||||
|
parts.coverage.unblock();
|
||||||
|
parts.sequences.clearSelection();
|
||||||
|
parts.sequences.setVisible(false);
|
||||||
|
},
|
||||||
|
|
||||||
|
destroy(viewer) {
|
||||||
|
parts.coverage.detach();
|
||||||
|
parts.coverage.clear();
|
||||||
|
parts.sequences.destroy(viewer);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** The filter changed: redraw. Each draw reads it from the context. */
|
||||||
|
setFilter() {
|
||||||
|
parts.coverage.rebuild();
|
||||||
|
parts.sequences.rerender();
|
||||||
|
},
|
||||||
|
|
||||||
|
coverageStats: () => parts.coverage.stats(),
|
||||||
|
|
||||||
|
/** A line selects its sequence; a cone opens its image. */
|
||||||
|
handlePick(pickId) {
|
||||||
|
const sequenceId = sequenceIdFromPick(pickId);
|
||||||
|
if (sequenceId) {
|
||||||
|
parts.sequences.select(sequenceId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (pickId.startsWith(PICK_PREFIX.image)) {
|
||||||
|
context.actions.openImage(pickId.slice(PICK_PREFIX.image.length));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
selectSequence: (sequenceId) => parts.sequences.select(sequenceId),
|
||||||
|
clearSequence: () => parts.sequences.clearSelection(),
|
||||||
|
sequenceStats: () => ({
|
||||||
|
selectedId: state.sequence.selectedId,
|
||||||
|
images: state.sequence.images.length,
|
||||||
|
loading: state.sequence.loading,
|
||||||
|
error: state.sequence.error,
|
||||||
|
}),
|
||||||
|
|
||||||
|
viewer:
|
||||||
|
photoViewer ||
|
||||||
|
createMapillaryViewer({ source, render: context.services?.render }),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { createMapillaryProvider } from './mapillaryProvider.js';
|
||||||
|
import { mapillaryImageUrl } from './policy.js';
|
||||||
|
import { sequenceIdFromPick } from './coverage.js';
|
||||||
|
import { thinImages } from './sequences.js';
|
||||||
|
import { validateProviders } from './registry.js';
|
||||||
|
import {
|
||||||
|
fakeMapillarySource,
|
||||||
|
rayCamera,
|
||||||
|
} from '../../testSupport/streetLevelFakes.mjs';
|
||||||
|
|
||||||
|
function fakeContext(filter = { pano: 'all', sinceMs: null }) {
|
||||||
|
const opened = [];
|
||||||
|
return {
|
||||||
|
opened,
|
||||||
|
services: {},
|
||||||
|
getFilter: () => filter,
|
||||||
|
isActive: () => true,
|
||||||
|
notify: () => {},
|
||||||
|
actions: { openImage: (id) => opened.push(id) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the definition satisfies the Street Level provider contract', () => {
|
||||||
|
const def = createMapillaryProvider({ source: fakeMapillarySource() });
|
||||||
|
assert.doesNotThrow(() => validateProviders([def]));
|
||||||
|
assert.equal(def.id, 'mapillary');
|
||||||
|
assert.equal(def.label, 'MAPILLARY');
|
||||||
|
assert.equal(def.requiresKeyId, 'mapillary');
|
||||||
|
assert.equal(def.pickPrefix, 'mly:');
|
||||||
|
assert.match(def.credit.html, /CC BY-SA 4\.0/);
|
||||||
|
// One colour per source: Mapillary is green everywhere it draws.
|
||||||
|
assert.equal(def.colors.coverage, '#05cb63');
|
||||||
|
assert.throws(() => createMapillaryProvider({ source: {} }), /source/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('image deep links match the mapillary.com share format', () => {
|
||||||
|
assert.equal(
|
||||||
|
mapillaryImageUrl(1814275685699406),
|
||||||
|
'https://www.mapillary.com/app/?pKey=1814275685699406&focus=photo',
|
||||||
|
);
|
||||||
|
assert.equal(mapillaryImageUrl(' '), 'https://www.mapillary.com/app/');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('picks route sequences to selection and images to the core opener', () => {
|
||||||
|
const context = fakeContext();
|
||||||
|
const instance = createMapillaryProvider({
|
||||||
|
source: fakeMapillarySource(),
|
||||||
|
}).create(context);
|
||||||
|
assert.equal(instance.handlePick('mly:img:77'), true);
|
||||||
|
assert.deepEqual(context.opened, ['77']);
|
||||||
|
assert.equal(instance.handlePick('cctv:1'), false);
|
||||||
|
assert.deepEqual(instance.sequenceStats(), {
|
||||||
|
selectedId: null,
|
||||||
|
images: 0,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a line pick names its sequence; anything else is not a sequence', () => {
|
||||||
|
assert.equal(sequenceIdFromPick('mly:seq:abc'), 'abc');
|
||||||
|
assert.equal(sequenceIdFromPick('mly:img:abc'), null);
|
||||||
|
assert.equal(sequenceIdFromPick(null), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cone thinning across the date line drops images metres apart', () => {
|
||||||
|
const image = (id, lon) => ({ id, lon, lat: 0 });
|
||||||
|
// A sequence driving east over ±180° with images ~1 m apart, then ~11 m on.
|
||||||
|
const kept = thinImages(
|
||||||
|
[
|
||||||
|
image('a', 179.99999),
|
||||||
|
image('b', -179.99999), // ~2 m from a, across the date line
|
||||||
|
image('c', -179.9999), // ~11 m from a
|
||||||
|
],
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
kept.map(({ id }) => id),
|
||||||
|
['a', 'c'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A viewer looking straight down on downtown Sacramento from 900 m. */
|
||||||
|
function streetViewer() {
|
||||||
|
const collection = () => ({
|
||||||
|
add: (item) => item,
|
||||||
|
remove: () => true,
|
||||||
|
contains: () => false,
|
||||||
|
get length() {
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const event = () => ({ addEventListener: () => () => {} });
|
||||||
|
const camera = Object.assign(
|
||||||
|
rayCamera({
|
||||||
|
lon: -121.4944,
|
||||||
|
lat: 38.5816,
|
||||||
|
altitude: 900,
|
||||||
|
pitch: -90,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
}),
|
||||||
|
{ changed: event(), moveEnd: event() },
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
camera,
|
||||||
|
scene: {
|
||||||
|
canvas: { clientWidth: 100, clientHeight: 100 },
|
||||||
|
globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 },
|
||||||
|
primitives: collection(),
|
||||||
|
groundPrimitives: collection(),
|
||||||
|
preRender: event(),
|
||||||
|
postRender: event(),
|
||||||
|
requestRender() {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('coverage asks for no tiles before the key status is known, and none without a key', async (t) => {
|
||||||
|
// Skip Cesium's one-time terrain table download for draped lines.
|
||||||
|
t.mock.method(
|
||||||
|
Cesium.GroundPolylinePrimitive,
|
||||||
|
'initializeTerrainHeights',
|
||||||
|
async () => {},
|
||||||
|
);
|
||||||
|
for (const configured of [false, true]) {
|
||||||
|
let answer;
|
||||||
|
const tiles = [];
|
||||||
|
const source = fakeMapillarySource({
|
||||||
|
hasToken: () => configured,
|
||||||
|
getStatus: () => new Promise((resolve) => (answer = resolve)),
|
||||||
|
getTile: (...args) => {
|
||||||
|
tiles.push(args);
|
||||||
|
return new Promise(() => {});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const instance = createMapillaryProvider({ source }).create(fakeContext());
|
||||||
|
const viewer = streetViewer();
|
||||||
|
instance.init(viewer);
|
||||||
|
const status = instance.status();
|
||||||
|
instance.activate(viewer);
|
||||||
|
assert.equal(tiles.length, 0, 'nothing before the status answers');
|
||||||
|
answer({ configured });
|
||||||
|
assert.deepEqual(await status, { configured });
|
||||||
|
if (configured)
|
||||||
|
assert.ok(tiles.length > 0, 'coverage loads as soon as the key is known');
|
||||||
|
else {
|
||||||
|
assert.equal(tiles.length, 0, 'a key-less install never asks');
|
||||||
|
assert.equal(instance.coverageStats().keyRequired, true);
|
||||||
|
}
|
||||||
|
instance.destroy(viewer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a status the server never answers falls back to the bundled token once the core gives up', async () => {
|
||||||
|
const source = fakeMapillarySource({
|
||||||
|
hasToken: () => false,
|
||||||
|
getStatus: ({ signal } = {}) =>
|
||||||
|
new Promise((_, reject) =>
|
||||||
|
signal?.addEventListener('abort', () => reject(signal.reason)),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const instance = createMapillaryProvider({ source }).create(fakeContext());
|
||||||
|
const controller = new AbortController();
|
||||||
|
const status = instance.status({ signal: controller.signal });
|
||||||
|
controller.abort(new Error('timed out'));
|
||||||
|
assert.deepEqual(await status, { configured: false });
|
||||||
|
assert.equal(instance.coverageStats().keyRequired, true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { normalizeFilter } from './filter.js';
|
||||||
|
import { encodeIdSwitches, decodeIdSwitches } from '../../data/idSwitches.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider params plus the filter. Custom switches share a stable ID-based
|
||||||
|
* field; Mapillary retains its existing boolean and share token.
|
||||||
|
* @param {{providers: Iterable<[string, boolean]>, filter: {pano: string, sinceDays: number}}} input
|
||||||
|
*/
|
||||||
|
export function encodeParams({ providers, filter }) {
|
||||||
|
const params = {};
|
||||||
|
const entries = [...providers];
|
||||||
|
for (const [id, on] of entries) params[id] = on === true;
|
||||||
|
const switches = encodeIdSwitches(
|
||||||
|
entries.filter(([id]) => id !== 'mapillary'),
|
||||||
|
);
|
||||||
|
if (switches) params.providerSwitches = switches;
|
||||||
|
params.pano = filter.pano;
|
||||||
|
params.sinceDays = filter.sinceDays;
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read params back, ignoring unknown providers and malformed values so a link
|
||||||
|
* from a build with more providers still applies.
|
||||||
|
* @param {object} params
|
||||||
|
* @param {{providerIds: Iterable<string>, filter: {pano: string, sinceDays: number}}} current
|
||||||
|
* @returns {{providers: Map<string, boolean>, filter: {pano: string, sinceDays: number}}}
|
||||||
|
*/
|
||||||
|
export function decodeParams(params, { providerIds, filter }) {
|
||||||
|
const providers = new Map();
|
||||||
|
const source = params && typeof params === 'object' ? params : {};
|
||||||
|
const switches = decodeIdSwitches(source.providerSwitches);
|
||||||
|
switches.delete('mapillary');
|
||||||
|
for (const id of providerIds) {
|
||||||
|
if (switches.has(id)) providers.set(id, switches.get(id));
|
||||||
|
else if (Object.hasOwn(source, id) && typeof source[id] === 'boolean')
|
||||||
|
providers.set(id, source[id]);
|
||||||
|
}
|
||||||
|
const next = {};
|
||||||
|
if ('pano' in source) next.pano = source.pano;
|
||||||
|
if ('sinceDays' in source) next.sinceDays = source.sinceDays;
|
||||||
|
return { providers, filter: normalizeFilter(next, filter) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { decodeParams, encodeParams } from './params.js';
|
||||||
|
|
||||||
|
test('encodeParams writes one boolean per provider plus the filter', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
encodeParams({
|
||||||
|
providers: [
|
||||||
|
['mapillary', true],
|
||||||
|
['panoramax', false],
|
||||||
|
],
|
||||||
|
filter: { pano: 'flat', sinceDays: 365 },
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
mapillary: true,
|
||||||
|
panoramax: false,
|
||||||
|
providerSwitches: 'panoramax-0',
|
||||||
|
pano: 'flat',
|
||||||
|
sinceDays: 365,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('decodeParams round-trips and ignores unknown or malformed keys', () => {
|
||||||
|
const current = { pano: 'all', sinceDays: 0 };
|
||||||
|
const decoded = decodeParams(
|
||||||
|
{ mapillary: false, kartaview: true, pano: 'pano', sinceDays: 730, x: 1 },
|
||||||
|
{ providerIds: ['mapillary'], filter: current },
|
||||||
|
);
|
||||||
|
assert.deepEqual([...decoded.providers], [['mapillary', false]]);
|
||||||
|
assert.deepEqual(decoded.filter, { pano: 'pano', sinceDays: 730 });
|
||||||
|
|
||||||
|
const partial = decodeParams(
|
||||||
|
{ mapillary: '1', pano: 'weird' },
|
||||||
|
{ providerIds: ['mapillary'], filter: { pano: 'flat', sinceDays: 5 } },
|
||||||
|
);
|
||||||
|
assert.equal(partial.providers.size, 0, 'a string is not a switch');
|
||||||
|
assert.deepEqual(partial.filter, { pano: 'flat', sinceDays: 5 });
|
||||||
|
|
||||||
|
const empty = decodeParams(null, {
|
||||||
|
providerIds: ['mapillary'],
|
||||||
|
filter: current,
|
||||||
|
});
|
||||||
|
assert.equal(empty.providers.size, 0);
|
||||||
|
assert.deepEqual(empty.filter, current);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('decodeParams applies "any date" (0 days) over a current window (share-link defaults)', () => {
|
||||||
|
const decoded = decodeParams(
|
||||||
|
{ sinceDays: 0 },
|
||||||
|
{ providerIds: ['mapillary'], filter: { pano: 'flat', sinceDays: 365 } },
|
||||||
|
);
|
||||||
|
assert.deepEqual(decoded.filter, { pano: 'flat', sinceDays: 0 });
|
||||||
|
// The provider switch off is a value too, not an absence.
|
||||||
|
assert.deepEqual(
|
||||||
|
[
|
||||||
|
...decodeParams(
|
||||||
|
{ mapillary: false },
|
||||||
|
{ providerIds: ['mapillary'], filter: { pano: 'all', sinceDays: 0 } },
|
||||||
|
).providers,
|
||||||
|
],
|
||||||
|
[['mapillary', false]],
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Route picked ids to the provider whose prefix they carry; the registry
|
||||||
|
* guarantees prefixes never overlap.
|
||||||
|
* @param {() => Iterable<{def: {id: string, pickPrefix: string}, instance: object}>} getProviders
|
||||||
|
* @param {{positionId?: string}} [options] Id of the core-owned position marker.
|
||||||
|
*/
|
||||||
|
export function createPickRouter(getProviders, { positionId = null } = {}) {
|
||||||
|
function resolve(id) {
|
||||||
|
if (typeof id !== 'string' || !id) return null;
|
||||||
|
if (positionId && id === positionId)
|
||||||
|
return { providerId: null, instance: null, id };
|
||||||
|
for (const entry of getProviders())
|
||||||
|
if (id.startsWith(entry.def.pickPrefix))
|
||||||
|
return { providerId: entry.def.id, instance: entry.instance, id };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ownsPick: (id) => resolve(id) !== null,
|
||||||
|
resolve,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createPickRouter } from './pickRouter.js';
|
||||||
|
|
||||||
|
const providers = [
|
||||||
|
{ def: { id: 'mapillary', pickPrefix: 'mly:' }, instance: { name: 'm' } },
|
||||||
|
{ def: { id: 'panoramax', pickPrefix: 'pnx:' }, instance: { name: 'p' } },
|
||||||
|
];
|
||||||
|
|
||||||
|
test('ids are routed by prefix to their provider instance', () => {
|
||||||
|
const router = createPickRouter(() => providers, { positionId: 'sl:pos' });
|
||||||
|
assert.deepEqual(router.resolve('mly:seq:1'), {
|
||||||
|
providerId: 'mapillary',
|
||||||
|
instance: providers[0].instance,
|
||||||
|
id: 'mly:seq:1',
|
||||||
|
});
|
||||||
|
assert.equal(router.resolve('pnx:img:9').providerId, 'panoramax');
|
||||||
|
assert.equal(router.ownsPick('mly:img:2'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the core-owned marker is owned but routes to no provider', () => {
|
||||||
|
const router = createPickRouter(() => providers, { positionId: 'sl:pos' });
|
||||||
|
assert.deepEqual(router.resolve('sl:pos'), {
|
||||||
|
providerId: null,
|
||||||
|
instance: null,
|
||||||
|
id: 'sl:pos',
|
||||||
|
});
|
||||||
|
assert.equal(router.ownsPick('sl:pos'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('foreign and malformed ids are not ours', () => {
|
||||||
|
const router = createPickRouter(() => providers);
|
||||||
|
for (const id of ['cctv:1', '', null, undefined, 42, {}])
|
||||||
|
assert.equal(router.ownsPick(id), false, String(id));
|
||||||
|
assert.equal(router.resolve('sl:pos'), null, 'no marker id configured');
|
||||||
|
});
|
||||||
@@ -1,8 +1,18 @@
|
|||||||
/** Identity and tuning for the Street Level layer and its Mapillary imagery. */
|
/** Identity and tuning for the Street Level layer and its Mapillary provider. */
|
||||||
export const STREET_LEVEL_LAYER_ID = 'street-level';
|
export const STREET_LEVEL_LAYER_ID = 'street-level';
|
||||||
|
export const MAPILLARY_PROVIDER_ID = 'mapillary';
|
||||||
|
export const MAPILLARY_NAME = 'Mapillary';
|
||||||
|
export const MAPILLARY_LABEL = 'MAPILLARY';
|
||||||
export const MAPILLARY_KEY_ID = 'mapillary';
|
export const MAPILLARY_KEY_ID = 'mapillary';
|
||||||
export const MAPILLARY_GRAPH_HOST = 'https://graph.mapillary.com';
|
export const MAPILLARY_GRAPH_HOST = 'https://graph.mapillary.com';
|
||||||
|
|
||||||
|
/** What the provider needs from its source (source.js or a stand-in). */
|
||||||
|
export const MAPILLARY_SOURCE_METHODS = Object.freeze([
|
||||||
|
'getStatus',
|
||||||
|
'getTile',
|
||||||
|
'getSequenceImages',
|
||||||
|
]);
|
||||||
|
|
||||||
/** Pick id of the viewer position marker. */
|
/** Pick id of the viewer position marker. */
|
||||||
export const POSITION_PICK_ID = 'sl:pos';
|
export const POSITION_PICK_ID = 'sl:pos';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { encodeParams, decodeParams } from './params.js';
|
||||||
|
import {
|
||||||
|
normalizeIdSwitches,
|
||||||
|
patchRequestedIdSwitches,
|
||||||
|
decodeIdSwitches,
|
||||||
|
encodeIdSwitches,
|
||||||
|
} from '../../data/idSwitches.js';
|
||||||
|
import {
|
||||||
|
createDefaultLayerState,
|
||||||
|
encodeLayerStateParams,
|
||||||
|
decodeLayerStateParams,
|
||||||
|
serializeStoredLayerState,
|
||||||
|
parseStoredLayerState,
|
||||||
|
} from '../../data/layerState.js';
|
||||||
|
import { validateProviders } from './registry.js';
|
||||||
|
import { fakeStreetLevelProvider } from '../../testSupport/streetLevelFakes.mjs';
|
||||||
|
|
||||||
|
const filter = { pano: 'flat', sinceDays: 365 };
|
||||||
|
|
||||||
|
test('custom switches survive sharing and storage without depending on provider order', () => {
|
||||||
|
const providers = [
|
||||||
|
['panoramax', false],
|
||||||
|
['example', true],
|
||||||
|
['mapillary', false],
|
||||||
|
];
|
||||||
|
const options = encodeParams({ providers, filter });
|
||||||
|
assert.equal(options.providerSwitches, 'example-1*panoramax-0');
|
||||||
|
assert.equal(
|
||||||
|
encodeParams({ providers: [...providers].reverse(), filter })
|
||||||
|
.providerSwitches,
|
||||||
|
options.providerSwitches,
|
||||||
|
);
|
||||||
|
const state = createDefaultLayerState();
|
||||||
|
state.enabledLayerIds = ['street-level'];
|
||||||
|
state.options['street-level'] = options;
|
||||||
|
const params = new URLSearchParams({ v: '2' });
|
||||||
|
encodeLayerStateParams(params, state);
|
||||||
|
assert.match(params.get('lo'), /0\.m\.0/);
|
||||||
|
assert.match(params.get('lo'), /0\.r\.example-1\*panoramax-0/);
|
||||||
|
assert.ok(
|
||||||
|
params.toString().includes('0.r.example-1*panoramax-0'),
|
||||||
|
'custom switches need no percent escaping',
|
||||||
|
);
|
||||||
|
for (const restored of [
|
||||||
|
decodeLayerStateParams(params),
|
||||||
|
parseStoredLayerState(serializeStoredLayerState(state)),
|
||||||
|
]) {
|
||||||
|
const decoded = decodeParams(restored.options['street-level'], {
|
||||||
|
providerIds: ['example', 'mapillary', 'panoramax'],
|
||||||
|
filter: { pano: 'all', sinceDays: 0 },
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
[...decoded.providers],
|
||||||
|
[
|
||||||
|
['example', true],
|
||||||
|
['mapillary', false],
|
||||||
|
['panoramax', false],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.deepEqual(decoded.filter, filter);
|
||||||
|
assert.deepEqual(
|
||||||
|
[
|
||||||
|
...decodeParams(restored.options['street-level'], {
|
||||||
|
providerIds: ['panoramax'],
|
||||||
|
filter,
|
||||||
|
}).providers,
|
||||||
|
],
|
||||||
|
[['panoramax', false]],
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
decodeParams(restored.options['street-level'], {
|
||||||
|
providerIds: [],
|
||||||
|
filter,
|
||||||
|
}).providers.size,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legacy links retain Mapillary semantics and leave custom defaults alone', () => {
|
||||||
|
for (const [lo, on] of [
|
||||||
|
['', true],
|
||||||
|
['0.m.0', false],
|
||||||
|
]) {
|
||||||
|
const state = decodeLayerStateParams(
|
||||||
|
new URLSearchParams({ v: '2', l: '0', lo }),
|
||||||
|
);
|
||||||
|
const restored = decodeParams(state.options['street-level'], {
|
||||||
|
providerIds: ['mapillary', 'example'],
|
||||||
|
filter,
|
||||||
|
});
|
||||||
|
assert.deepEqual([...restored.providers], [['mapillary', on]]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('switches reject ambiguous or oversized fields and cannot override the legacy token', () => {
|
||||||
|
for (const value of [
|
||||||
|
'example-0*example-1',
|
||||||
|
'mapillary-0',
|
||||||
|
'__proto__-1',
|
||||||
|
'a-2',
|
||||||
|
'a-1*',
|
||||||
|
'a.b-1',
|
||||||
|
'a_b-0',
|
||||||
|
`${'a'.repeat(255)}-1`,
|
||||||
|
null,
|
||||||
|
{},
|
||||||
|
]) {
|
||||||
|
assert.equal(normalizeIdSwitches(value, ['mapillary']), null);
|
||||||
|
}
|
||||||
|
assert.equal(normalizeIdSwitches('constructor-0'), 'constructor-0');
|
||||||
|
assert.throws(
|
||||||
|
() => encodeIdSwitches([['a'.repeat(255), true]]),
|
||||||
|
/share-link/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => validateProviders([fakeStreetLevelProvider({ id: 'a'.repeat(255) })]),
|
||||||
|
/share-link/,
|
||||||
|
);
|
||||||
|
const decoded = decodeParams(
|
||||||
|
{ providerSwitches: 'mapillary-0', mapillary: true },
|
||||||
|
{ providerIds: ['mapillary'], filter },
|
||||||
|
);
|
||||||
|
assert.equal(decoded.providers.get('mapillary'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('neutral switch codec preserves hyphens and numeric suffixes in IDs', () => {
|
||||||
|
const switches = [
|
||||||
|
['provider-0', false],
|
||||||
|
['provider-1', true],
|
||||||
|
['a-b-c', true],
|
||||||
|
];
|
||||||
|
const encoded = encodeIdSwitches(switches);
|
||||||
|
assert.equal(encoded, 'a-b-c-1*provider-0-0*provider-1-1');
|
||||||
|
const decoded = decodeIdSwitches(encoded);
|
||||||
|
for (const [id, on] of switches) assert.equal(decoded.get(id), on);
|
||||||
|
assert.equal(decodeIdSwitches('valid-1*broken').size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('editing a receiving composition drops unknown IDs before enforcing the share bound', () => {
|
||||||
|
const previous = `${'x'.repeat(254)}-1`;
|
||||||
|
assert.equal(normalizeIdSwitches(previous), previous);
|
||||||
|
assert.equal(
|
||||||
|
patchRequestedIdSwitches(previous, 'alpha-0*beta-0', { alpha: false }),
|
||||||
|
'alpha-0',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
patchRequestedIdSwitches(previous, 'alpha-0', { pano: 'flat' }),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { encodeIdSwitches } from '../../data/idSwitches.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The contract an imagery provider registers with the Street Level layer.
|
||||||
|
*
|
||||||
|
* @typedef {object} StreetLevelProvider
|
||||||
|
* @property {string} id Stable id, e.g. 'mapillary'; also the share-link option key.
|
||||||
|
* @property {string} name Human name for captions, e.g. 'Mapillary'.
|
||||||
|
* @property {string} label Chip text and link label, e.g. 'MAPILLARY'.
|
||||||
|
* @property {string|null} requiresKeyId Key-setup id the provider needs, or null when keyless.
|
||||||
|
* @property {string} pickPrefix Every primitive id the provider creates starts with it.
|
||||||
|
* @property {{coverage: string}} colors The source's one colour.
|
||||||
|
* @property {{html: string}} credit On-globe attribution while the provider is active.
|
||||||
|
* @property {(context: ProviderContext) => ProviderInstance} create
|
||||||
|
*
|
||||||
|
* @typedef {object} ProviderContext Handed to `create()` once by the core.
|
||||||
|
* @property {object} services Scene services: picking, input, render, sprites.
|
||||||
|
* @property {() => {pano: string, sinceMs: number|null}} getFilter Filter resolved against now; read it on every use so "since N days" keeps up.
|
||||||
|
* @property {() => boolean} isActive Layer enabled and this provider switched on.
|
||||||
|
* @property {() => void} notify Ask the core to publish a new UI snapshot.
|
||||||
|
* @property {{openImage: (imageId: string) => Promise<boolean>}} actions
|
||||||
|
*
|
||||||
|
* @typedef {object} ProviderInstance
|
||||||
|
* @property {(options?: {signal?: AbortSignal}) => Promise<{configured: boolean}>} status Whether a usable key is set; the core gives up on a hung answer through `signal`.
|
||||||
|
* @property {(viewer: object) => void} init Create collections, hidden.
|
||||||
|
* @property {(viewer: object) => void} activate Start drawing coverage for the camera.
|
||||||
|
* @property {() => void} deactivate Detach, clear coverage and selection, hide.
|
||||||
|
* @property {(viewer: object) => void} destroy
|
||||||
|
* @property {(filter: {pano: string, sinceMs: number|null}) => void} setFilter The filter changed: redraw (reading `getFilter()`).
|
||||||
|
* @property {() => {count: number, loading: boolean, hint: string, error: string|null, keyRequired: boolean, keyRejected?: boolean}} coverageStats
|
||||||
|
* @property {(pickId: string) => boolean} handlePick The id carries the provider's own prefix.
|
||||||
|
* @property {(sequenceId: string) => Promise<void>} [selectSequence]
|
||||||
|
* @property {() => void} [clearSequence]
|
||||||
|
* @property {() => {selectedId: string|null, images: number, loading: boolean, error?: string|null}} [sequenceStats]
|
||||||
|
* @property {ViewerAdapter} viewer
|
||||||
|
*
|
||||||
|
* @typedef {object} ViewerAdapter
|
||||||
|
* @property {(host: HTMLElement) => Promise<void>} mount Idempotent; may lazy-load a library.
|
||||||
|
* @property {(imageId: string) => Promise<void>} open Resolves once the image is on screen and a pose was emitted.
|
||||||
|
* @property {() => void} close Drop the image, keep the instance warm.
|
||||||
|
* @property {() => void} unmount Destroy the instance (host handed to another provider).
|
||||||
|
* @property {() => void} resize
|
||||||
|
* @property {(mode: 'letterbox'|'fill') => void} [setRenderMode]
|
||||||
|
* @property {(listener: (pose: StreetPose) => void) => () => void} onPose
|
||||||
|
*
|
||||||
|
* @typedef {object} StreetPose
|
||||||
|
* @property {string} imageId
|
||||||
|
* @property {{lon: number, lat: number}} position
|
||||||
|
* @property {number|null} bearing
|
||||||
|
* @property {number|null} altitude
|
||||||
|
* @property {boolean} isPano
|
||||||
|
* @property {number|null} capturedAt Epoch milliseconds.
|
||||||
|
* @property {string|null} creator
|
||||||
|
* @property {string|null} sequenceId
|
||||||
|
* @property {string} externalUrl Deep link to the image on the provider's site.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const REQUIRED = Object.freeze([
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'label',
|
||||||
|
'pickPrefix',
|
||||||
|
'colors',
|
||||||
|
'credit',
|
||||||
|
'create',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ID_GRAMMAR = /^[a-z][a-z0-9-]*$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate providers and freeze their order, which is also the chip order.
|
||||||
|
* @param {Array<StreetLevelProvider>} providers
|
||||||
|
* @returns {ReadonlyArray<StreetLevelProvider>}
|
||||||
|
*/
|
||||||
|
export function validateProviders(providers) {
|
||||||
|
if (!Array.isArray(providers))
|
||||||
|
throw new TypeError('Street Level providers must be an array');
|
||||||
|
const ids = new Set();
|
||||||
|
const prefixes = [];
|
||||||
|
for (const provider of providers) {
|
||||||
|
const label = provider?.id ?? '(unnamed)';
|
||||||
|
for (const key of REQUIRED)
|
||||||
|
if (provider?.[key] === undefined || provider?.[key] === null)
|
||||||
|
throw new TypeError(`Street Level provider ${label} lacks ${key}`);
|
||||||
|
if (typeof provider.id !== 'string' || !ID_GRAMMAR.test(provider.id))
|
||||||
|
throw new TypeError(`Street Level provider id ${label} is not a slug`);
|
||||||
|
if (ids.has(provider.id))
|
||||||
|
throw new TypeError(`Duplicate Street Level provider ${provider.id}`);
|
||||||
|
if (typeof provider.create !== 'function')
|
||||||
|
throw new TypeError(
|
||||||
|
`Street Level provider ${label}: create is not a function`,
|
||||||
|
);
|
||||||
|
if (typeof provider.pickPrefix !== 'string' || !provider.pickPrefix)
|
||||||
|
throw new TypeError(`Street Level provider ${label} needs a pick prefix`);
|
||||||
|
for (const other of prefixes)
|
||||||
|
if (
|
||||||
|
other.startsWith(provider.pickPrefix) ||
|
||||||
|
provider.pickPrefix.startsWith(other)
|
||||||
|
)
|
||||||
|
throw new TypeError(
|
||||||
|
`Street Level provider ${label}: pick prefix ${provider.pickPrefix} overlaps ${other}`,
|
||||||
|
);
|
||||||
|
if (!provider.credit?.html)
|
||||||
|
throw new TypeError(`Street Level provider ${label} needs a credit`);
|
||||||
|
if (
|
||||||
|
typeof provider.colors?.coverage !== 'string' ||
|
||||||
|
!provider.colors.coverage
|
||||||
|
)
|
||||||
|
throw new TypeError(
|
||||||
|
`Street Level provider ${label} needs a coverage colour`,
|
||||||
|
);
|
||||||
|
ids.add(provider.id);
|
||||||
|
prefixes.push(provider.pickPrefix);
|
||||||
|
}
|
||||||
|
// Validate the complete bounded share field once, before any instance exists.
|
||||||
|
try {
|
||||||
|
encodeIdSwitches(
|
||||||
|
providers
|
||||||
|
.filter(({ id }) => id !== 'mapillary')
|
||||||
|
.map(({ id }) => [id, true]),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof RangeError)) throw error;
|
||||||
|
throw new RangeError(
|
||||||
|
'Street Level provider IDs cannot fit in the 256-character share-link field; use shorter IDs or fewer providers',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Object.freeze([...providers]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The key id every provider shares, or null if any is keyless or they differ
|
||||||
|
* (then each chip reports its own need).
|
||||||
|
* @param {ReadonlyArray<StreetLevelProvider>} providers
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
export function requiresKeyIdFor(providers) {
|
||||||
|
const keys = new Set(providers.map((p) => p.requiresKeyId || null));
|
||||||
|
if (keys.size !== 1) return null;
|
||||||
|
return [...keys][0];
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { requiresKeyIdFor, validateProviders } from './registry.js';
|
||||||
|
|
||||||
|
const def = (overrides = {}) => ({
|
||||||
|
id: 'mapillary',
|
||||||
|
name: 'Mapillary',
|
||||||
|
label: 'MAPILLARY',
|
||||||
|
requiresKeyId: 'mapillary',
|
||||||
|
pickPrefix: 'mly:',
|
||||||
|
colors: { coverage: '#05cb63' },
|
||||||
|
credit: { html: 'Mapillary' },
|
||||||
|
create: () => ({}),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a valid list is frozen in registration order', () => {
|
||||||
|
const list = validateProviders([
|
||||||
|
def(),
|
||||||
|
def({ id: 'panoramax', pickPrefix: 'pnx:', requiresKeyId: null }),
|
||||||
|
]);
|
||||||
|
assert.ok(Object.isFrozen(list));
|
||||||
|
assert.deepEqual(
|
||||||
|
list.map((p) => p.id),
|
||||||
|
['mapillary', 'panoramax'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty provider list is valid and has no key requirement', () => {
|
||||||
|
assert.deepEqual(validateProviders([]), []);
|
||||||
|
assert.ok(Object.isFrozen(validateProviders([])));
|
||||||
|
assert.equal(requiresKeyIdFor([]), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-arrays, duplicate ids and malformed ids are rejected', () => {
|
||||||
|
assert.throws(() => validateProviders(null), /array/);
|
||||||
|
assert.throws(() => validateProviders([def(), def()]), /Duplicate/);
|
||||||
|
assert.throws(() => validateProviders([def({ id: 'Bad Id' })]), /slug/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every required field is checked by name', () => {
|
||||||
|
for (const key of [
|
||||||
|
'name',
|
||||||
|
'label',
|
||||||
|
'pickPrefix',
|
||||||
|
'colors',
|
||||||
|
'credit',
|
||||||
|
'create',
|
||||||
|
])
|
||||||
|
assert.throws(
|
||||||
|
() => validateProviders([def({ [key]: undefined })]),
|
||||||
|
new RegExp(`lacks ${key}`),
|
||||||
|
key,
|
||||||
|
);
|
||||||
|
assert.throws(() => validateProviders([def({ create: 1 })]), /create/);
|
||||||
|
assert.throws(() => validateProviders([def({ credit: {} })]), /credit/);
|
||||||
|
assert.throws(
|
||||||
|
() => validateProviders([def({ colors: { pano: '#f0f' } })]),
|
||||||
|
/coverage colour/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => validateProviders([def({ pickPrefix: '' })]),
|
||||||
|
/pick prefix/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overlapping pick prefixes are rejected either way round', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => validateProviders([def(), def({ id: 'other', pickPrefix: 'mly:x' })]),
|
||||||
|
/overlaps/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
validateProviders([def({ pickPrefix: 'mly:x' }), def({ id: 'other' })]),
|
||||||
|
/overlaps/,
|
||||||
|
);
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
validateProviders([def(), def({ id: 'other', pickPrefix: 'ml:' })]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requiresKeyIdFor is the shared key, or null once providers differ', () => {
|
||||||
|
assert.equal(requiresKeyIdFor([def()]), 'mapillary');
|
||||||
|
assert.equal(
|
||||||
|
requiresKeyIdFor([def(), def({ id: 'b', pickPrefix: 'b:' })]),
|
||||||
|
'mapillary',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
requiresKeyIdFor([
|
||||||
|
def(),
|
||||||
|
def({ id: 'b', pickPrefix: 'b:', requiresKeyId: null }),
|
||||||
|
]),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
requiresKeyIdFor([
|
||||||
|
def(),
|
||||||
|
def({ id: 'b', pickPrefix: 'b:', requiresKeyId: 'google-maps' }),
|
||||||
|
]),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert.equal(requiresKeyIdFor([def({ requiresKeyId: null })]), null);
|
||||||
|
});
|
||||||
@@ -1,37 +1,22 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import {
|
import { STREET_LEVEL_LAYER_ID } from './policy.js';
|
||||||
PICK_PREFIX,
|
|
||||||
POSITION_PICK_ID,
|
|
||||||
STREET_LEVEL_LAYER_ID,
|
|
||||||
} from './policy.js';
|
|
||||||
import { sequenceIdFromPick } from './coverage.js';
|
|
||||||
|
|
||||||
/** One click handler for coverage lines, image cones and the marker. */
|
/** One click handler for every provider's lines and cones, and the marker. */
|
||||||
export function createSelection({ state, parts }) {
|
export function createSelection({ state, parts }) {
|
||||||
const { picking, input } = state.services;
|
const { picking, input } = state.services;
|
||||||
|
|
||||||
/** Lines and cones (`mly:`) and the position marker are this layer's. */
|
/** The id carries a provider's prefix: that provider handles the pick. */
|
||||||
function ownsPick(id) {
|
|
||||||
return (
|
|
||||||
typeof id === 'string' &&
|
|
||||||
(id === POSITION_PICK_ID || id.startsWith(PICK_PREFIX.root))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A line selects its sequence; a cone opens its image. */
|
|
||||||
function onClick(click) {
|
function onClick(click) {
|
||||||
const viewer = state.viewer;
|
const viewer = state.viewer;
|
||||||
if (!viewer || !state.enabled || !state.providerOn) return;
|
if (!viewer || !state.enabled) return;
|
||||||
if (input?.isPointerFree && !input.isPointerFree()) return;
|
if (input?.isPointerFree && !input.isPointerFree()) return;
|
||||||
const picked = viewer.scene.pick(click.position);
|
const picked = viewer.scene.pick(click.position);
|
||||||
const id = picking?.resolvePickId
|
const id = picking?.resolvePickId
|
||||||
? picking.resolvePickId(picked)
|
? picking.resolvePickId(picked)
|
||||||
: picked?.id;
|
: picked?.id;
|
||||||
if (typeof id !== 'string') return;
|
const hit = parts.router.resolve(id);
|
||||||
const sequenceId = sequenceIdFromPick(id);
|
// The position marker is the core's own: nothing to select.
|
||||||
if (sequenceId) parts.sequences.select(sequenceId);
|
if (hit?.instance) hit.instance.handlePick(hit.id);
|
||||||
else if (id.startsWith(PICK_PREFIX.image))
|
|
||||||
parts.openImage(id.slice(PICK_PREFIX.image.length));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,9 +34,9 @@ export function createSelection({ state, parts }) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
if (!state.sequence.selectedId) return;
|
if (!parts.hasSelectedSequence()) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
parts.sequences.clearSelection();
|
parts.clearSequences();
|
||||||
}
|
}
|
||||||
|
|
||||||
function install(viewer) {
|
function install(viewer) {
|
||||||
@@ -64,7 +49,7 @@ export function createSelection({ state, parts }) {
|
|||||||
Cesium.ScreenSpaceEventType.LEFT_CLICK,
|
Cesium.ScreenSpaceEventType.LEFT_CLICK,
|
||||||
);
|
);
|
||||||
document.addEventListener('keydown', onKeyDown);
|
document.addEventListener('keydown', onKeyDown);
|
||||||
picking?.registerPickOwner?.(STREET_LEVEL_LAYER_ID, ownsPick);
|
picking?.registerPickOwner?.(STREET_LEVEL_LAYER_ID, parts.router.ownsPick);
|
||||||
}
|
}
|
||||||
|
|
||||||
function uninstall() {
|
function uninstall() {
|
||||||
@@ -76,5 +61,5 @@ export function createSelection({ state, parts }) {
|
|||||||
picking?.unregisterPickOwner?.(STREET_LEVEL_LAYER_ID);
|
picking?.unregisterPickOwner?.(STREET_LEVEL_LAYER_ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { install, uninstall, ownsPick };
|
return { install, uninstall, ownsPick: parts.router.ownsPick };
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { createSelection } from './selection.js';
|
import { createSelection } from './selection.js';
|
||||||
|
import { createPickRouter } from './pickRouter.js';
|
||||||
|
|
||||||
/** The selection installed on a stand-in viewer, with what its clicks reach. */
|
/** The selection installed on a stand-in viewer, with what its clicks reach. */
|
||||||
function harness({ selected = false } = {}) {
|
function harness({ selected = false } = {}) {
|
||||||
@@ -38,28 +39,36 @@ function harness({ selected = false } = {}) {
|
|||||||
const state = {
|
const state = {
|
||||||
viewer,
|
viewer,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
providerOn: true,
|
|
||||||
clickHandler: null,
|
clickHandler: null,
|
||||||
services: { picking: null, input: null },
|
services: { picking: null, input: null },
|
||||||
sequence: {
|
providers: new Map([
|
||||||
get selectedId() {
|
[
|
||||||
return sequences.selected ? 'seq-1' : null;
|
'mapillary',
|
||||||
},
|
{
|
||||||
|
def: { id: 'mapillary', pickPrefix: 'mly:' },
|
||||||
|
instance: {
|
||||||
|
handlePick(id) {
|
||||||
|
if (id.startsWith('mly:seq:'))
|
||||||
|
sequences.selects.push(id.slice(8));
|
||||||
|
else if (id.startsWith('mly:img:')) opened.push(id.slice(8));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
const parts = {
|
||||||
|
router: createPickRouter(() => state.providers.values(), {
|
||||||
|
positionId: 'sl:pos',
|
||||||
|
}),
|
||||||
|
hasSelectedSequence: () => sequences.selected,
|
||||||
|
clearSequences() {
|
||||||
|
sequences.cleared++;
|
||||||
|
sequences.selected = false;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const selection = createSelection({
|
const selection = createSelection({ state, parts });
|
||||||
state,
|
|
||||||
parts: {
|
|
||||||
sequences: {
|
|
||||||
select: (id) => sequences.selects.push(id),
|
|
||||||
clearSelection() {
|
|
||||||
sequences.cleared++;
|
|
||||||
sequences.selected = false;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
openImage: (id) => opened.push(id),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
selection.install(viewer);
|
selection.install(viewer);
|
||||||
const action = (type) =>
|
const action = (type) =>
|
||||||
state.clickHandler.getInputAction(Cesium.ScreenSpaceEventType[type]);
|
state.clickHandler.getInputAction(Cesium.ScreenSpaceEventType[type]);
|
||||||
@@ -103,11 +112,6 @@ test('a click on a line selects its sequence, on a cone opens its image, elsewhe
|
|||||||
h.click();
|
h.click();
|
||||||
}
|
}
|
||||||
assert.deepEqual([h.sequences.selects, h.opened], [['abc'], ['42']]);
|
assert.deepEqual([h.sequences.selects, h.opened], [['abc'], ['42']]);
|
||||||
// Mapillary switched off under a layer that is on: lines are not drawn.
|
|
||||||
h.state.providerOn = false;
|
|
||||||
h.scene.under = 'mly:seq:abc';
|
|
||||||
h.click();
|
|
||||||
assert.deepEqual(h.sequences.selects, ['abc']);
|
|
||||||
} finally {
|
} finally {
|
||||||
h.restore();
|
h.restore();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { imageConeGlyph } from './glyphs.js';
|
import { imageConeGlyph } from './glyphs.js';
|
||||||
import { passesImageryFilter, resolveFilter } from './filter.js';
|
import { passesImageryFilter } from './filter.js';
|
||||||
import { createHorizonCull, metresBetween } from './view.js';
|
import { createHorizonCull, metresBetween } from './view.js';
|
||||||
import {
|
import {
|
||||||
COLORS,
|
COLORS,
|
||||||
@@ -89,7 +89,7 @@ export function createSequences({ state, source, parts }) {
|
|||||||
pano: true,
|
pano: true,
|
||||||
});
|
});
|
||||||
// Resolved now, so a "since N days" window keeps up with the clock.
|
// Resolved now, so a "since N days" window keeps up with the clock.
|
||||||
const filter = resolveFilter(state.filter);
|
const filter = state.getFilter();
|
||||||
for (const image of images) {
|
for (const image of images) {
|
||||||
if (!passesImageryFilter(image, filter)) continue;
|
if (!passesImageryFilter(image, filter)) continue;
|
||||||
const billboard = collection.add({
|
const billboard = collection.add({
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
|
import { resolveFilter } from './filter.js';
|
||||||
import { createSequences } from './sequences.js';
|
import { createSequences } from './sequences.js';
|
||||||
|
|
||||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
@@ -56,6 +57,7 @@ function setup() {
|
|||||||
services: {},
|
services: {},
|
||||||
viewer: {},
|
viewer: {},
|
||||||
filter: { pano: 'all', sinceDays: 0 },
|
filter: { pano: 'all', sinceDays: 0 },
|
||||||
|
getFilter: () => resolveFilter(state.filter),
|
||||||
notify() {},
|
notify() {},
|
||||||
sequence: {
|
sequence: {
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { FILTER_DEFAULT } from './policy.js';
|
|||||||
export function freshStreet() {
|
export function freshStreet() {
|
||||||
return {
|
return {
|
||||||
open: false,
|
open: false,
|
||||||
|
providerId: null,
|
||||||
|
providerName: null,
|
||||||
|
providerLabel: null,
|
||||||
imageId: null,
|
imageId: null,
|
||||||
position: null,
|
position: null,
|
||||||
bearing: null,
|
bearing: null,
|
||||||
@@ -21,7 +24,7 @@ export function freshStreet() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Coverage bookkeeping, before the first refresh. */
|
/** A provider's coverage bookkeeping, before the first refresh. */
|
||||||
export function freshCoverage() {
|
export function freshCoverage() {
|
||||||
return {
|
return {
|
||||||
zoom: null,
|
zoom: null,
|
||||||
@@ -34,7 +37,7 @@ export function freshCoverage() {
|
|||||||
lastError: null,
|
lastError: null,
|
||||||
/**
|
/**
|
||||||
* Why tile requests are paused, or null: 'status' until the key status
|
* Why tile requests are paused, or null: 'status' until the key status
|
||||||
* answers, 'no-key', 'rejected' (until the layer goes off) or
|
* answers, 'no-key', 'rejected' (until the provider goes off) or
|
||||||
* 'rate-limited' (until `blockTimer` fires).
|
* 'rate-limited' (until `blockTimer` fires).
|
||||||
*/
|
*/
|
||||||
blocked: 'status',
|
blocked: 'status',
|
||||||
@@ -46,7 +49,7 @@ export function freshCoverage() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mutable layer state, created once per layer instance. */
|
/** Mutable core state, created once per layer instance. */
|
||||||
export function createState({ services }) {
|
export function createState({ services }) {
|
||||||
return {
|
return {
|
||||||
services,
|
services,
|
||||||
@@ -54,10 +57,10 @@ export function createState({ services }) {
|
|||||||
enabled: false,
|
enabled: false,
|
||||||
listeners: new Set(),
|
listeners: new Set(),
|
||||||
notify: null,
|
notify: null,
|
||||||
/** The share link's `mapillary` switch: off draws nothing. */
|
/** Shared imagery filter, in the stored (relative-days) form. */
|
||||||
providerOn: true,
|
|
||||||
/** Imagery filter, in the stored (relative-days) form. */
|
|
||||||
filter: { ...FILTER_DEFAULT },
|
filter: { ...FILTER_DEFAULT },
|
||||||
|
/** @type {Map<string, {def: object, instance: object, on: boolean, status: object|null}>} */
|
||||||
|
providers: new Map(),
|
||||||
|
|
||||||
street: {
|
street: {
|
||||||
host: null,
|
host: null,
|
||||||
@@ -66,23 +69,7 @@ export function createState({ services }) {
|
|||||||
...freshStreet(),
|
...freshStreet(),
|
||||||
},
|
},
|
||||||
|
|
||||||
coverage: freshCoverage(),
|
|
||||||
sequence: {
|
|
||||||
selectedId: null,
|
|
||||||
images: [],
|
|
||||||
collection: null,
|
|
||||||
loading: false,
|
|
||||||
/** Why the selected sequence's images could not load, until it goes. */
|
|
||||||
error: null,
|
|
||||||
abort: null,
|
|
||||||
},
|
|
||||||
|
|
||||||
marker: { collection: null, billboard: null },
|
marker: { collection: null, billboard: null },
|
||||||
clickHandler: null,
|
clickHandler: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Coverage, cones and lookups run only while the layer and its switch are on. */
|
|
||||||
export function isActive(state) {
|
|
||||||
return state.enabled && state.providerOn;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* @typedef {object} ProviderSnapshot
|
||||||
|
* @property {string} id
|
||||||
|
* @property {string} name
|
||||||
|
* @property {string} label
|
||||||
|
* @property {boolean} on
|
||||||
|
* @property {boolean|null} configured null until the status call answers
|
||||||
|
* @property {boolean} keyRequired no usable key: missing or rejected
|
||||||
|
* @property {boolean} [keyRejected] the provider refused the configured key
|
||||||
|
* @property {string|null} requiresKeyId
|
||||||
|
* @property {boolean} loading
|
||||||
|
* @property {number} count
|
||||||
|
* @property {string} hint
|
||||||
|
* @property {string|null} error
|
||||||
|
* @property {string} color The source's one colour.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Totals over switched-on providers. Key-gated only when every one lacks its
|
||||||
|
* key; `keyRejected` when that is because a key was refused.
|
||||||
|
* @param {Array<ProviderSnapshot>} providers
|
||||||
|
*/
|
||||||
|
export function summarizeCoverage(providers) {
|
||||||
|
const active = providers.filter((p) => p.on);
|
||||||
|
return {
|
||||||
|
count: active.reduce((sum, p) => sum + (p.count || 0), 0),
|
||||||
|
loading: active.some((p) => p.loading),
|
||||||
|
hint:
|
||||||
|
providers.length === 0
|
||||||
|
? 'No street-level imagery providers configured'
|
||||||
|
: active.find((p) => p.hint)?.hint || '',
|
||||||
|
error: active.find((p) => p.error)?.error || null,
|
||||||
|
keyRequired:
|
||||||
|
active.length > 0 && active.every((p) => p.keyRequired === true),
|
||||||
|
keyRejected:
|
||||||
|
active.length > 0 &&
|
||||||
|
active.every((p) => p.keyRequired === true) &&
|
||||||
|
active.some((p) => p.keyRejected === true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel snapshot from core state and provider snapshots.
|
||||||
|
* @param {{enabled: boolean, filter: object, providers: Array<ProviderSnapshot>, street: object, sequence: object}} input
|
||||||
|
*/
|
||||||
|
export function composeUIState({
|
||||||
|
enabled,
|
||||||
|
filter,
|
||||||
|
providers,
|
||||||
|
street,
|
||||||
|
sequence,
|
||||||
|
}) {
|
||||||
|
const { keyRequired, keyRejected, ...coverage } =
|
||||||
|
summarizeCoverage(providers);
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
keyRequired,
|
||||||
|
keyRejected,
|
||||||
|
filter: { ...filter },
|
||||||
|
providers: providers.map((p) => ({ ...p })),
|
||||||
|
coverage,
|
||||||
|
sequence: { ...sequence },
|
||||||
|
street: { ...street },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { composeUIState, summarizeCoverage } from './uiState.js';
|
||||||
|
import { providerSnapshot } from '../../testSupport/streetLevelFakes.mjs';
|
||||||
|
|
||||||
|
const provider = (overrides = {}) =>
|
||||||
|
providerSnapshot({ count: 10, ...overrides });
|
||||||
|
const base = {
|
||||||
|
enabled: true,
|
||||||
|
filter: { pano: 'all', sinceDays: 0 },
|
||||||
|
street: { open: false },
|
||||||
|
sequence: { selectedId: null, images: 0, loading: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
test('counts add up across active providers only', () => {
|
||||||
|
const ui = composeUIState({
|
||||||
|
...base,
|
||||||
|
providers: [
|
||||||
|
provider(),
|
||||||
|
provider({ id: 'panoramax', name: 'Panoramax', count: 5, on: false }),
|
||||||
|
provider({ id: 'kartaview', name: 'KartaView', count: 7, loading: true }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.equal(ui.coverage.count, 17);
|
||||||
|
assert.equal(ui.coverage.loading, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the layer is key-gated only when every switched-on provider lacks its key', () => {
|
||||||
|
const gated = composeUIState({
|
||||||
|
...base,
|
||||||
|
providers: [provider({ keyRequired: true })],
|
||||||
|
});
|
||||||
|
assert.equal(gated.keyRequired, true);
|
||||||
|
const mixed = composeUIState({
|
||||||
|
...base,
|
||||||
|
providers: [
|
||||||
|
provider({ keyRequired: true }),
|
||||||
|
provider({ id: 'panoramax', name: 'Panoramax', requiresKeyId: null }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.equal(mixed.keyRequired, false, 'a keyless provider still draws');
|
||||||
|
const offOnly = composeUIState({
|
||||||
|
...base,
|
||||||
|
providers: [provider({ keyRequired: true, on: false })],
|
||||||
|
});
|
||||||
|
assert.equal(offOnly.keyRequired, false, 'nothing switched on to gate');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hint and error come from the first active provider that has one', () => {
|
||||||
|
const ui = composeUIState({
|
||||||
|
...base,
|
||||||
|
providers: [
|
||||||
|
provider({ hint: '', error: null }),
|
||||||
|
provider({ id: 'b', name: 'B', hint: 'Look down', error: 'boom' }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.equal(ui.coverage.hint, 'Look down');
|
||||||
|
assert.equal(ui.coverage.error, 'boom');
|
||||||
|
assert.deepEqual(ui.filter, base.filter);
|
||||||
|
assert.notEqual(ui.filter, base.filter, 'snapshot copies the filter');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summarizeCoverage is what getStats reports, without building a snapshot', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
summarizeCoverage([
|
||||||
|
provider({ count: 4, hint: 'Look down' }),
|
||||||
|
provider({ id: 'b', name: 'B', count: 6, loading: true, error: 'boom' }),
|
||||||
|
provider({ id: 'c', name: 'C', count: 99, on: false, keyRequired: true }),
|
||||||
|
]),
|
||||||
|
{
|
||||||
|
count: 10,
|
||||||
|
loading: true,
|
||||||
|
hint: 'Look down',
|
||||||
|
error: 'boom',
|
||||||
|
keyRequired: false,
|
||||||
|
keyRejected: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(summarizeCoverage([]).keyRequired, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a rejected key gates like a missing one and is named as rejected', () => {
|
||||||
|
const rejected = provider({
|
||||||
|
keyRequired: true,
|
||||||
|
keyRejected: true,
|
||||||
|
error: 'Mapillary rejected MAPILLARY_CLIENT_TOKEN',
|
||||||
|
});
|
||||||
|
const summary = summarizeCoverage([rejected]);
|
||||||
|
assert.equal(summary.keyRequired, true);
|
||||||
|
assert.equal(summary.keyRejected, true);
|
||||||
|
assert.equal(summary.error, 'Mapillary rejected MAPILLARY_CLIENT_TOKEN');
|
||||||
|
const state = composeUIState({ ...base, providers: [rejected] });
|
||||||
|
assert.equal(state.keyRequired, true);
|
||||||
|
assert.equal(state.keyRejected, true);
|
||||||
|
// A provider that is merely missing its key is not "rejected".
|
||||||
|
assert.equal(
|
||||||
|
summarizeCoverage([provider({ keyRequired: true })]).keyRejected,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import { freshStreet } from './state.js';
|
import { freshStreet } from './state.js';
|
||||||
|
|
||||||
/** The panel's photo viewer: its poses update `state.street` and the marker. */
|
/**
|
||||||
export function createViewerHost({ state, parts, adapter }) {
|
* The panel's photo viewer element and the provider adapter mounted in it.
|
||||||
/** Set once mounted, until `unmount`. */
|
* Adapter poses update `state.street` and the marker.
|
||||||
let unsubscribe = null;
|
*/
|
||||||
/** The mount in flight, shared by concurrent opens. */
|
export function createViewerHost({ state, parts }) {
|
||||||
|
/** @type {{id: string, adapter: object, unsubscribe: () => void}|null} */
|
||||||
|
let active = null;
|
||||||
|
/** The mount in flight, shared by concurrent opens. @type {{id: string, promise: Promise<object>}|null} */
|
||||||
let mounting = null;
|
let mounting = null;
|
||||||
let openSeq = 0;
|
let openSeq = 0;
|
||||||
|
|
||||||
@@ -13,7 +16,7 @@ export function createViewerHost({ state, parts, adapter }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyPose(pose) {
|
function applyPose(pose) {
|
||||||
if (!unsubscribe || !state.street.open) return;
|
if (!active || !state.street.open) return;
|
||||||
const previousSequence = state.street.sequenceId;
|
const previousSequence = state.street.sequenceId;
|
||||||
Object.assign(state.street, {
|
Object.assign(state.street, {
|
||||||
imageId: pose.imageId,
|
imageId: pose.imageId,
|
||||||
@@ -35,43 +38,57 @@ export function createViewerHost({ state, parts, adapter }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function selectCurrentSequence() {
|
function selectCurrentSequence() {
|
||||||
const { sequenceId } = state.street;
|
const { sequenceId, providerId } = state.street;
|
||||||
if (!sequenceId || !state.enabled) return;
|
const entry = state.providers.get(providerId);
|
||||||
if (state.sequence.selectedId !== sequenceId)
|
if (!sequenceId || !entry?.instance.selectSequence || !state.enabled)
|
||||||
parts.sequences.select(sequenceId);
|
return;
|
||||||
|
if (entry.instance.sequenceStats?.()?.selectedId !== sequenceId)
|
||||||
|
entry.instance.selectSequence(sequenceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mount the viewer. It counts as mounted only on success, so a failed
|
* Mount a provider's adapter, releasing another provider's first. It
|
||||||
* mount is retried on the next open; concurrent opens share one.
|
* counts as mounted only on success, so a failed mount is retried on the
|
||||||
|
* next open; concurrent opens share one.
|
||||||
*/
|
*/
|
||||||
function mount() {
|
function mount(entry) {
|
||||||
if (unsubscribe) return Promise.resolve();
|
const id = entry.def.id;
|
||||||
if (mounting) return mounting;
|
if (active?.id === id) return Promise.resolve(active.adapter);
|
||||||
|
if (mounting?.id === id) return mounting.promise;
|
||||||
|
if (active) {
|
||||||
|
active.adapter.close();
|
||||||
|
active.unsubscribe();
|
||||||
|
active.adapter.unmount();
|
||||||
|
active = null;
|
||||||
|
}
|
||||||
|
const adapter = entry.instance.viewer;
|
||||||
const promise = (async () => {
|
const promise = (async () => {
|
||||||
await adapter.mount(state.street.host);
|
await adapter.mount(state.street.host);
|
||||||
if (mounting !== promise) {
|
if (mounting?.promise !== promise) {
|
||||||
// Unmounted (layer off) while loading. A newer mount shares the
|
// Unmounted (layer off, provider switched) while loading. A newer
|
||||||
// viewer: tearing it down here would leave that mount without one.
|
// mount of this provider shares the adapter: tearing it down here
|
||||||
if (!mounting && !unsubscribe) adapter.unmount();
|
// would leave that mount without a viewer.
|
||||||
|
if (active?.id !== id && mounting?.id !== id) adapter.unmount();
|
||||||
throw new Error('Street-level viewer was closed');
|
throw new Error('Street-level viewer was closed');
|
||||||
}
|
}
|
||||||
// Listen only once current: an outdated mount releasing the same
|
// Listen only once current: an outdated mount releasing the same
|
||||||
// `applyPose` would otherwise unhook the mount that replaced it.
|
// `applyPose` would otherwise unhook the mount that replaced it.
|
||||||
unsubscribe = adapter.onPose(applyPose);
|
active = { id, adapter, unsubscribe: adapter.onPose(applyPose) };
|
||||||
adapter.setRenderMode?.(state.street.renderMode);
|
adapter.setRenderMode?.(state.street.renderMode);
|
||||||
|
return adapter;
|
||||||
})();
|
})();
|
||||||
mounting = promise;
|
mounting = { id, adapter, promise };
|
||||||
const settle = () => {
|
const settle = () => {
|
||||||
if (mounting === promise) mounting = null;
|
if (mounting?.promise === promise) mounting = null;
|
||||||
};
|
};
|
||||||
promise.then(settle, settle);
|
promise.then(settle, settle);
|
||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open an image: true once its first pose is in, false if it failed or was overtaken. */
|
/** Open an image: true once its first pose is in, false if it failed or was overtaken. */
|
||||||
async function open(imageId) {
|
async function open(providerId, imageId) {
|
||||||
if (!imageId) return false;
|
const entry = state.providers.get(providerId);
|
||||||
|
if (!entry || !imageId) return false;
|
||||||
if (!state.street.host) {
|
if (!state.street.host) {
|
||||||
state.street.error = 'Open the Street Level panel to view imagery';
|
state.street.error = 'Open the Street Level panel to view imagery';
|
||||||
notify();
|
notify();
|
||||||
@@ -81,10 +98,17 @@ export function createViewerHost({ state, parts, adapter }) {
|
|||||||
const current = () => seq === openSeq;
|
const current = () => seq === openSeq;
|
||||||
// Claimed now, honoured after loading only if nothing newer took the camera.
|
// Claimed now, honoured after loading only if nothing newer took the camera.
|
||||||
const ticket = parts.framing.begin();
|
const ticket = parts.framing.begin();
|
||||||
Object.assign(state.street, { loading: true, error: null, open: true });
|
Object.assign(state.street, {
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
open: true,
|
||||||
|
providerId,
|
||||||
|
providerName: entry.def.name,
|
||||||
|
providerLabel: entry.def.label,
|
||||||
|
});
|
||||||
notify();
|
notify();
|
||||||
try {
|
try {
|
||||||
await mount();
|
const adapter = await mount(entry);
|
||||||
if (!current()) return false;
|
if (!current()) return false;
|
||||||
await adapter.open(String(imageId));
|
await adapter.open(String(imageId));
|
||||||
if (!current()) return false;
|
if (!current()) return false;
|
||||||
@@ -101,11 +125,11 @@ export function createViewerHost({ state, parts, adapter }) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Close the image and stop any framing flight; the viewer stays mounted. */
|
/** Close the image and stop any framing flight; the adapter stays mounted. */
|
||||||
function close() {
|
function close() {
|
||||||
openSeq++;
|
openSeq++;
|
||||||
parts.framing.cancel();
|
parts.framing.cancel();
|
||||||
if (unsubscribe) adapter.close();
|
active?.adapter.close();
|
||||||
Object.assign(state.street, freshStreet());
|
Object.assign(state.street, freshStreet());
|
||||||
parts.marker.clear();
|
parts.marker.clear();
|
||||||
notify();
|
notify();
|
||||||
@@ -117,22 +141,29 @@ export function createViewerHost({ state, parts, adapter }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resize() {
|
function resize() {
|
||||||
if (unsubscribe) adapter.resize();
|
active?.adapter.resize();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setRenderMode(mode) {
|
function setRenderMode(mode) {
|
||||||
state.street.renderMode = mode === 'fill' ? 'fill' : 'letterbox';
|
state.street.renderMode = mode === 'fill' ? 'fill' : 'letterbox';
|
||||||
if (unsubscribe) adapter.setRenderMode?.(state.street.renderMode);
|
active?.adapter.setRenderMode?.(state.street.renderMode);
|
||||||
notify();
|
notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Close the image and destroy the viewer (and its WebGL context). */
|
/**
|
||||||
function unmount() {
|
* Close the image and destroy the viewer (and its WebGL context): every
|
||||||
|
* provider's, or only `providerId`'s. A mount still in flight is told too,
|
||||||
|
* so it builds nothing when its library lands.
|
||||||
|
*/
|
||||||
|
function unmount(providerId) {
|
||||||
|
if (providerId && active?.id !== providerId && mounting?.id !== providerId)
|
||||||
|
return;
|
||||||
close();
|
close();
|
||||||
|
const pending = mounting;
|
||||||
mounting = null;
|
mounting = null;
|
||||||
unsubscribe?.();
|
active?.unsubscribe();
|
||||||
unsubscribe = null;
|
(active?.adapter ?? pending?.adapter)?.unmount();
|
||||||
adapter.unmount();
|
active = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { attach, open, close, resize, setRenderMode, unmount };
|
return { attach, open, close, resize, setRenderMode, unmount };
|
||||||
|
|||||||
@@ -35,13 +35,29 @@ const { createMapillaryViewer } = await import('./viewer.js');
|
|||||||
* `selected` every sequence the layer was asked to select.
|
* `selected` every sequence the layer was asked to select.
|
||||||
*/
|
*/
|
||||||
function harness(adapter) {
|
function harness(adapter) {
|
||||||
|
const selected = [];
|
||||||
const state = {
|
const state = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
notify() {},
|
notify() {},
|
||||||
street: { host: {}, renderMode: 'letterbox' },
|
street: { host: {}, renderMode: 'letterbox' },
|
||||||
sequence: { selectedId: null },
|
sequence: { selectedId: null },
|
||||||
|
providers: new Map([
|
||||||
|
[
|
||||||
|
'mapillary',
|
||||||
|
{
|
||||||
|
def: { id: 'mapillary', name: 'Mapillary', label: 'MAPILLARY' },
|
||||||
|
instance: {
|
||||||
|
viewer: adapter,
|
||||||
|
selectSequence(id) {
|
||||||
|
selected.push(id);
|
||||||
|
state.sequence.selectedId = id;
|
||||||
|
},
|
||||||
|
sequenceStats: () => ({ selectedId: state.sequence.selectedId }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]),
|
||||||
};
|
};
|
||||||
const selected = [];
|
|
||||||
const framing = { begun: 0, started: 0, cancelled: 0 };
|
const framing = { begun: 0, started: 0, cancelled: 0 };
|
||||||
const marker = [];
|
const marker = [];
|
||||||
const parts = {
|
const parts = {
|
||||||
@@ -55,12 +71,6 @@ function harness(adapter) {
|
|||||||
frame: (ticket) => ticket && framing.started++,
|
frame: (ticket) => ticket && framing.started++,
|
||||||
cancel: () => framing.cancelled++,
|
cancel: () => framing.cancelled++,
|
||||||
},
|
},
|
||||||
sequences: {
|
|
||||||
select(id) {
|
|
||||||
selected.push(id);
|
|
||||||
state.sequence.selectedId = id;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
@@ -71,7 +81,7 @@ function harness(adapter) {
|
|||||||
clearSequence() {
|
clearSequence() {
|
||||||
state.sequence.selectedId = null;
|
state.sequence.selectedId = null;
|
||||||
},
|
},
|
||||||
host: createViewerHost({ state, parts, adapter }),
|
host: createViewerHost({ state, parts }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,10 +142,10 @@ function fakeAdapter({ failMounts = 0, gate = null, openGate = null } = {}) {
|
|||||||
test('a failed mount is retried on the next open instead of being cached', async () => {
|
test('a failed mount is retried on the next open instead of being cached', async () => {
|
||||||
const adapter = fakeAdapter({ failMounts: 1 });
|
const adapter = fakeAdapter({ failMounts: 1 });
|
||||||
const { state, host } = harness(adapter);
|
const { state, host } = harness(adapter);
|
||||||
await host.open('a');
|
await host.open('mapillary', 'a');
|
||||||
assert.equal(state.street.error, 'library failed to load');
|
assert.equal(state.street.error, 'library failed to load');
|
||||||
assert.equal(adapter.calls.listeners, 0, 'the pose listener was released');
|
assert.equal(adapter.calls.listeners, 0, 'the pose listener was released');
|
||||||
await host.open('b');
|
await host.open('mapillary', 'b');
|
||||||
assert.equal(state.street.error, null);
|
assert.equal(state.street.error, null);
|
||||||
assert.equal(adapter.calls.mount, 2);
|
assert.equal(adapter.calls.mount, 2);
|
||||||
assert.deepEqual(adapter.calls.open, ['b']);
|
assert.deepEqual(adapter.calls.open, ['b']);
|
||||||
@@ -146,7 +156,7 @@ test('a failed mount is retried on the next open instead of being cached', async
|
|||||||
test('concurrent opens share one mount and one pose listener', async () => {
|
test('concurrent opens share one mount and one pose listener', async () => {
|
||||||
const adapter = fakeAdapter();
|
const adapter = fakeAdapter();
|
||||||
const { host } = harness(adapter);
|
const { host } = harness(adapter);
|
||||||
await Promise.all([host.open('a'), host.open('b')]);
|
await Promise.all([host.open('mapillary', 'a'), host.open('mapillary', 'b')]);
|
||||||
assert.equal(adapter.calls.mount, 1);
|
assert.equal(adapter.calls.mount, 1);
|
||||||
assert.equal(adapter.calls.listeners, 1);
|
assert.equal(adapter.calls.listeners, 1);
|
||||||
});
|
});
|
||||||
@@ -154,14 +164,14 @@ test('concurrent opens share one mount and one pose listener', async () => {
|
|||||||
test('unmounting while a mount is in flight does not leave it active', async () => {
|
test('unmounting while a mount is in flight does not leave it active', async () => {
|
||||||
const adapter = fakeAdapter();
|
const adapter = fakeAdapter();
|
||||||
const { state, host } = harness(adapter);
|
const { state, host } = harness(adapter);
|
||||||
const opening = host.open('a');
|
const opening = host.open('mapillary', 'a');
|
||||||
host.unmount();
|
host.unmount();
|
||||||
await opening;
|
await opening;
|
||||||
assert.equal(adapter.calls.listeners, 0);
|
assert.equal(adapter.calls.listeners, 0);
|
||||||
assert.equal(adapter.calls.unmount, 2, 'layer off, then the late mount');
|
assert.equal(adapter.calls.unmount, 2, 'layer off, then the late mount');
|
||||||
assert.equal(state.street.open, false);
|
assert.equal(state.street.open, false);
|
||||||
// Not left active: the next open mounts again.
|
// Not left active: the next open mounts again.
|
||||||
await host.open('b');
|
await host.open('mapillary', 'b');
|
||||||
assert.equal(adapter.calls.mount, 2);
|
assert.equal(adapter.calls.mount, 2);
|
||||||
assert.equal(state.street.imageId, 'b');
|
assert.equal(state.street.imageId, 'b');
|
||||||
});
|
});
|
||||||
@@ -169,7 +179,7 @@ test('unmounting while a mount is in flight does not leave it active', async ()
|
|||||||
test('a pose that arrives after the photo was closed does not bring it back', async () => {
|
test('a pose that arrives after the photo was closed does not bring it back', async () => {
|
||||||
const adapter = fakeAdapter();
|
const adapter = fakeAdapter();
|
||||||
const { state, host } = harness(adapter);
|
const { state, host } = harness(adapter);
|
||||||
await host.open('a');
|
await host.open('mapillary', 'a');
|
||||||
host.close();
|
host.close();
|
||||||
const before = { ...state.street };
|
const before = { ...state.street };
|
||||||
// The library's `image` event for a photo that was still loading.
|
// The library's `image` event for a photo that was still loading.
|
||||||
@@ -193,7 +203,7 @@ const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|||||||
test('every pose the viewer reports moves the marker; only the open frames the camera', async () => {
|
test('every pose the viewer reports moves the marker; only the open frames the camera', async () => {
|
||||||
const adapter = fakeAdapter();
|
const adapter = fakeAdapter();
|
||||||
const { host, marker, framing } = harness(adapter);
|
const { host, marker, framing } = harness(adapter);
|
||||||
await host.open('a');
|
await host.open('mapillary', 'a');
|
||||||
assert.deepEqual(marker, [{ position: { lon: 1, lat: 2 }, bearing: 90 }]);
|
assert.deepEqual(marker, [{ position: { lon: 1, lat: 2 }, bearing: 90 }]);
|
||||||
// The user walks on: the marker goes along, the globe camera stays.
|
// The user walks on: the marker goes along, the globe camera stays.
|
||||||
adapter.emitPose('b', { position: { lon: 3, lat: 4 }, bearing: 180 });
|
adapter.emitPose('b', { position: { lon: 3, lat: 4 }, bearing: 180 });
|
||||||
@@ -211,7 +221,7 @@ test('a pose on another sequence selects it, but not while the image still loads
|
|||||||
const openGate = deferred();
|
const openGate = deferred();
|
||||||
const adapter = fakeAdapter({ openGate });
|
const adapter = fakeAdapter({ openGate });
|
||||||
const { state, host, selected, clearSequence } = harness(adapter);
|
const { state, host, selected, clearSequence } = harness(adapter);
|
||||||
const opening = host.open('a');
|
const opening = host.open('mapillary', 'a');
|
||||||
await settle();
|
await settle();
|
||||||
assert.equal(state.street.loading, true, 'the image is downloading');
|
assert.equal(state.street.loading, true, 'the image is downloading');
|
||||||
assert.equal(state.street.sequenceId, 'seq-a', 'its pose is in');
|
assert.equal(state.street.sequenceId, 'seq-a', 'its pose is in');
|
||||||
@@ -234,7 +244,7 @@ test('a pose on another sequence selects it, but not while the image still loads
|
|||||||
test('closing the photo clears the marker', async () => {
|
test('closing the photo clears the marker', async () => {
|
||||||
const adapter = fakeAdapter();
|
const adapter = fakeAdapter();
|
||||||
const { host, marker } = harness(adapter);
|
const { host, marker } = harness(adapter);
|
||||||
await host.open('a');
|
await host.open('mapillary', 'a');
|
||||||
assert.notEqual(marker.at(-1), null);
|
assert.notEqual(marker.at(-1), null);
|
||||||
host.close();
|
host.close();
|
||||||
assert.equal(marker.at(-1), null);
|
assert.equal(marker.at(-1), null);
|
||||||
@@ -244,7 +254,7 @@ test('an open still mounting when the photo is closed does nothing when the moun
|
|||||||
const gate = deferred();
|
const gate = deferred();
|
||||||
const adapter = fakeAdapter({ gate });
|
const adapter = fakeAdapter({ gate });
|
||||||
const { state, host, framing, selected } = harness(adapter);
|
const { state, host, framing, selected } = harness(adapter);
|
||||||
const opening = host.open('a'); // MapillaryJS still loading
|
const opening = host.open('mapillary', 'a'); // MapillaryJS still loading
|
||||||
await settle();
|
await settle();
|
||||||
host.close();
|
host.close();
|
||||||
gate.resolve();
|
gate.resolve();
|
||||||
@@ -257,7 +267,7 @@ test('an open still mounting when the photo is closed does nothing when the moun
|
|||||||
[false, false, null],
|
[false, false, null],
|
||||||
);
|
);
|
||||||
// The mounted viewer serves the next open.
|
// The mounted viewer serves the next open.
|
||||||
assert.equal(await host.open('b'), true);
|
assert.equal(await host.open('mapillary', 'b'), true);
|
||||||
assert.equal(state.street.imageId, 'b');
|
assert.equal(state.street.imageId, 'b');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -265,7 +275,7 @@ test('an image still opening when the photo is closed is neither framed nor sele
|
|||||||
const openGate = deferred();
|
const openGate = deferred();
|
||||||
const adapter = fakeAdapter({ openGate });
|
const adapter = fakeAdapter({ openGate });
|
||||||
const { state, host, framing, selected } = harness(adapter);
|
const { state, host, framing, selected } = harness(adapter);
|
||||||
const opening = host.open('a');
|
const opening = host.open('mapillary', 'a');
|
||||||
await settle();
|
await settle();
|
||||||
assert.deepEqual(adapter.calls.open, ['a'], 'the image is downloading');
|
assert.deepEqual(adapter.calls.open, ['a'], 'the image is downloading');
|
||||||
host.close();
|
host.close();
|
||||||
@@ -343,16 +353,16 @@ test('the Mapillary viewer survives the layer going off and on during its first
|
|||||||
const { gate, viewers } = fakeLibrary();
|
const { gate, viewers } = fakeLibrary();
|
||||||
const adapter = createMapillaryViewer({ source: { token: 't' } });
|
const adapter = createMapillaryViewer({ source: { token: 't' } });
|
||||||
const { state, host } = harness(adapter);
|
const { state, host } = harness(adapter);
|
||||||
const first = host.open('img1');
|
const first = host.open('mapillary', 'img1');
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
host.unmount();
|
host.unmount();
|
||||||
const second = host.open('img2');
|
const second = host.open('mapillary', 'img2');
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
gate.resolve();
|
gate.resolve();
|
||||||
await Promise.all([first, second]);
|
await Promise.all([first, second]);
|
||||||
assert.equal(state.street.error, null);
|
assert.equal(state.street.error, null);
|
||||||
assert.equal(state.street.imageId, 'img2');
|
assert.equal(state.street.imageId, 'img2');
|
||||||
await host.open('img3');
|
await host.open('mapillary', 'img3');
|
||||||
assert.equal(state.street.error, null, 'later opens are not stuck');
|
assert.equal(state.street.error, null, 'later opens are not stuck');
|
||||||
assert.equal(state.street.imageId, 'img3');
|
assert.equal(state.street.imageId, 'img3');
|
||||||
assert.equal(viewers.live, 1);
|
assert.equal(viewers.live, 1);
|
||||||
@@ -379,7 +389,7 @@ test('closing the photo stops sequence playback in the hidden viewer', async ()
|
|||||||
gate.resolve();
|
gate.resolve();
|
||||||
const adapter = createMapillaryViewer({ source: { token: 't' } });
|
const adapter = createMapillaryViewer({ source: { token: 't' } });
|
||||||
const { host } = harness(adapter);
|
const { host } = harness(adapter);
|
||||||
await host.open('img1');
|
await host.open('mapillary', 'img1');
|
||||||
const [viewer] = viewers.instances;
|
const [viewer] = viewers.instances;
|
||||||
assert.equal(viewer.playback.stops, 0);
|
assert.equal(viewer.playback.stops, 0);
|
||||||
host.close();
|
host.close();
|
||||||
@@ -455,7 +465,7 @@ test('a photo asks for the camera when it starts opening and frames with that ti
|
|||||||
const openGate = deferred();
|
const openGate = deferred();
|
||||||
const adapter = fakeAdapter({ openGate });
|
const adapter = fakeAdapter({ openGate });
|
||||||
const { host, framing } = harness(adapter);
|
const { host, framing } = harness(adapter);
|
||||||
const opening = host.open('a');
|
const opening = host.open('mapillary', 'a');
|
||||||
assert.equal(framing.begun, 1, 'claimed before the download');
|
assert.equal(framing.begun, 1, 'claimed before the download');
|
||||||
await settle();
|
await settle();
|
||||||
assert.equal(framing.started, 0);
|
assert.equal(framing.started, 0);
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export function createIngestion({
|
|||||||
feed.acceptedRowCount = snapshot.acceptedRowCount;
|
feed.acceptedRowCount = snapshot.acceptedRowCount;
|
||||||
feed.partial = payload?.complete === false;
|
feed.partial = payload?.complete === false;
|
||||||
|
|
||||||
if (snapshot.acceptedRowCount === 0) {
|
if (snapshot.acceptedRowCount === 0 && !snapshot.emptyCoverage) {
|
||||||
feed.count = readCount();
|
feed.count = readCount();
|
||||||
feed.stale = feed.count > 0 || Boolean(payload?.refreshing);
|
feed.stale = feed.count > 0 || Boolean(payload?.refreshing);
|
||||||
if (isDefinitiveTransportFailure(snapshot.transportStatus)) {
|
if (isDefinitiveTransportFailure(snapshot.transportStatus)) {
|
||||||
|
|||||||
@@ -361,3 +361,47 @@ test('partial vessel snapshots preserve freshness and recover after a complete u
|
|||||||
layer.destroy();
|
layer.destroy();
|
||||||
assert.equal(layer.getStats().partial, false);
|
assert.equal(layer.getStats().partial, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('authoritative empty coverage clears old contacts without declaring an outage', async () => {
|
||||||
|
let current = snapshot([observation('111')]);
|
||||||
|
const { layer } = setup({
|
||||||
|
async getSnapshot() {
|
||||||
|
return current;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await layer.update();
|
||||||
|
current = snapshot([], { observedAtMs: null, lastMessageAt: 500 });
|
||||||
|
await layer.update();
|
||||||
|
assert.ok(!layer.hasContact('111'));
|
||||||
|
assert.equal(layer.getStats().count, 0);
|
||||||
|
assert.equal(layer.getStats().error, null);
|
||||||
|
assert.equal(layer.getStats().stale, false);
|
||||||
|
assert.equal(layer.getStats().lastUpdate, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty coverage cannot hide stale, incomplete or rejected source data', async () => {
|
||||||
|
for (const patch of [
|
||||||
|
{ freshness: 'unknown' },
|
||||||
|
{ freshness: 'stale' },
|
||||||
|
{ complete: false },
|
||||||
|
{ rawRowCount: 1 },
|
||||||
|
{ transportStatus: 'stale' },
|
||||||
|
{ lastMessageAt: null },
|
||||||
|
]) {
|
||||||
|
let current = snapshot([observation('111')]);
|
||||||
|
const { layer } = setup({
|
||||||
|
async getSnapshot() {
|
||||||
|
return current;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await layer.update();
|
||||||
|
current = snapshot([], {
|
||||||
|
observedAtMs: null,
|
||||||
|
lastMessageAt: 500,
|
||||||
|
...patch,
|
||||||
|
});
|
||||||
|
await layer.update();
|
||||||
|
assert.equal(layer.hasContact('111'), true, JSON.stringify(patch));
|
||||||
|
assert.ok(layer.getStats().error, JSON.stringify(patch));
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -119,7 +119,16 @@ export function createQueries({
|
|||||||
typeof payload?.status === 'string' ? payload.status : null;
|
typeof payload?.status === 'string' ? payload.status : null;
|
||||||
const lastMessageAt = payload?.lastMessageAt ?? null;
|
const lastMessageAt = payload?.lastMessageAt ?? null;
|
||||||
const acceptedRowCount = acceptedRows.length;
|
const acceptedRowCount = acceptedRows.length;
|
||||||
|
const emptyCoverage =
|
||||||
|
rawRows.length === 0 &&
|
||||||
|
payload?.rawRowCount === 0 &&
|
||||||
|
payload?.complete === true &&
|
||||||
|
payload?.freshness === 'current' &&
|
||||||
|
AIS_HEALTHY_STATUSES.has(transportStatus) &&
|
||||||
|
Number.isFinite(Number(lastMessageAt)) &&
|
||||||
|
Number(lastMessageAt) > 0;
|
||||||
return {
|
return {
|
||||||
|
emptyCoverage,
|
||||||
transportStatus,
|
transportStatus,
|
||||||
lastMessageAt,
|
lastMessageAt,
|
||||||
rawRows,
|
rawRows,
|
||||||
@@ -130,9 +139,10 @@ export function createQueries({
|
|||||||
? payload.rawRowCount
|
? payload.rawRowCount
|
||||||
: rawRows.length,
|
: rawRows.length,
|
||||||
acceptedRowCount,
|
acceptedRowCount,
|
||||||
error:
|
error: emptyCoverage
|
||||||
deriveAisFeedError(payload, acceptedRowCount) ||
|
? null
|
||||||
(acceptedRowCount === 0 ? 'awaiting usable AIS positions…' : null),
|
: deriveAisFeedError(payload, acceptedRowCount) ||
|
||||||
|
(acceptedRowCount === 0 ? 'awaiting usable AIS positions…' : null),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -274,6 +274,13 @@ test('doctor describes the credential ladder without exposing values', () => {
|
|||||||
);
|
);
|
||||||
assert.match(capabilities.map, /Bing and world-terrain stacks/);
|
assert.match(capabilities.map, /Bing and world-terrain stacks/);
|
||||||
assert.equal(capabilities.voice, 'available');
|
assert.equal(capabilities.voice, 'available');
|
||||||
|
assert.equal(
|
||||||
|
buildCapabilitySummary({
|
||||||
|
...credentials,
|
||||||
|
OPENAI_API_KEY: { configured: false },
|
||||||
|
}).voice,
|
||||||
|
'off until an OpenAI key is added, or a Codex ChatGPT sign-in is selected in Provider Settings (experimental)',
|
||||||
|
);
|
||||||
assert.match(capabilities.missions, /token allowance/);
|
assert.match(capabilities.missions, /token allowance/);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
capabilities.flights,
|
capabilities.flights,
|
||||||
|
|||||||
@@ -214,11 +214,18 @@ function decodeLayers(bytes, z, x, y, layerNames) {
|
|||||||
return { roads, military };
|
return { roads, military };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Construct an immutable-version road/military tile source without starting I/O. */
|
/**
|
||||||
|
* Construct an immutable-version road/military tile source without starting I/O.
|
||||||
|
*
|
||||||
|
* `allowedOrigin` is deliberately not set here: `createVectorTileSource`
|
||||||
|
* derives it from whichever `tileJsonUrl` is in effect, so a caller that
|
||||||
|
* repoints only `tileJsonUrl` gets tiles from that host or a named error —
|
||||||
|
* never a silent fall back to the default host (#933). Passing
|
||||||
|
* `allowedOrigin` explicitly still overrides both.
|
||||||
|
*/
|
||||||
export function createOpenFreeMapSource(options = {}) {
|
export function createOpenFreeMapSource(options = {}) {
|
||||||
return createVectorTileSource({
|
return createVectorTileSource({
|
||||||
tileJsonUrl: 'https://tiles.openfreemap.org/planet',
|
tileJsonUrl: 'https://tiles.openfreemap.org/planet',
|
||||||
allowedOrigin: 'https://tiles.openfreemap.org',
|
|
||||||
decode: decodeOpenFreeMapTile,
|
decode: decodeOpenFreeMapTile,
|
||||||
maxEntries: 192,
|
maxEntries: 192,
|
||||||
maxCacheBytes: 64 * 1024 * 1024,
|
maxCacheBytes: 64 * 1024 * 1024,
|
||||||
|
|||||||
@@ -320,3 +320,41 @@ test('Capitol, greenbelt and commercial property fixtures exclude non-public dri
|
|||||||
assert.ok(records.some((r) => r.area === area && !r.drivable));
|
assert.ok(records.some((r) => r.area === area && !r.drivable));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('repointing tileJsonUrl at a mirror never falls back to the default tile host', async () => {
|
||||||
|
const urls = [];
|
||||||
|
const mirror = 'http://127.0.0.1:12348';
|
||||||
|
const source = createOpenFreeMapSource({
|
||||||
|
fetchImpl: async (url) => {
|
||||||
|
urls.push(url);
|
||||||
|
return url.endsWith('/planet')
|
||||||
|
? Response.json({ tiles: [`${mirror}/planet/v/{z}/{x}/{y}.pbf`] })
|
||||||
|
: new Response(fixture('ofm-austin-14-3743-6745.pbf'));
|
||||||
|
},
|
||||||
|
tileJsonUrl: `${mirror}/planet`,
|
||||||
|
});
|
||||||
|
await source.fetchBounds(
|
||||||
|
{ south: 30.264, north: 30.271, west: -97.75, east: -97.74 },
|
||||||
|
{ zoom: 14 },
|
||||||
|
);
|
||||||
|
assert.ok(urls.length > 1, 'tiles were fetched');
|
||||||
|
assert.deepEqual(
|
||||||
|
urls.filter((url) => !url.startsWith(mirror)),
|
||||||
|
[],
|
||||||
|
'a repointed source must not fetch from tiles.openfreemap.org',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a mirror that serves the default host TileJSON verbatim fails loudly', async () => {
|
||||||
|
// A transparent caching proxy returns upstream's TileJSON unchanged, whose
|
||||||
|
// tiles[0] is an absolute upstream URL. That must be an error, not 100% of
|
||||||
|
// tile traffic quietly leaving the configured mirror.
|
||||||
|
const source = createOpenFreeMapSource({
|
||||||
|
fetchImpl: async () =>
|
||||||
|
Response.json({
|
||||||
|
tiles: ['https://tiles.openfreemap.org/planet/v/{z}/{x}/{y}.pbf'],
|
||||||
|
}),
|
||||||
|
tileJsonUrl: 'http://127.0.0.1:12348/planet',
|
||||||
|
});
|
||||||
|
await assert.rejects(source.getMetadata(), { retryable: false });
|
||||||
|
});
|
||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Parse three-line TLE catalog text into `{ name, line1, line2 }` entries.
|
* Parse three-line TLE catalog text into `{ name, line1, line2 }` entries.
|
||||||
* Blocks whose second and third lines are not TLE lines 1 and 2 are skipped.
|
* Malformed sets are skipped without dropping the valid sets that follow.
|
||||||
*/
|
*/
|
||||||
export function parseTleText(text) {
|
export function parseTleText(text) {
|
||||||
const lines = String(text)
|
const lines = String(text)
|
||||||
@@ -9,12 +9,13 @@ export function parseTleText(text) {
|
|||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.filter((line) => line.length > 0);
|
.filter((line) => line.length > 0);
|
||||||
const result = [];
|
const result = [];
|
||||||
for (let i = 0; i < lines.length - 2; i += 3) {
|
for (let i = 0; i < lines.length - 2; i++) {
|
||||||
const name = lines[i];
|
const name = lines[i];
|
||||||
const line1 = lines[i + 1];
|
const line1 = lines[i + 1];
|
||||||
const line2 = lines[i + 2];
|
const line2 = lines[i + 2];
|
||||||
if (line1.startsWith('1 ') && line2.startsWith('2 ')) {
|
if (line1.startsWith('1 ') && line2.startsWith('2 ')) {
|
||||||
result.push({ name, line1, line2 });
|
result.push({ name, line1, line2 });
|
||||||
|
i += 2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -16,11 +16,27 @@ export function validTileBounds(box) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The origin a configured TileJSON is served from, or undefined when there is
|
||||||
|
* no absolute URL to read one off. Deriving `allowedOrigin` from it means
|
||||||
|
* repointing `tileJsonUrl` at a mirror cannot silently keep loading tiles from
|
||||||
|
* the old host: the origin check below then names the mismatch instead of
|
||||||
|
* passing it (#933).
|
||||||
|
*/
|
||||||
|
function tileJsonOrigin(tileJsonUrl) {
|
||||||
|
if (typeof tileJsonUrl !== 'string') return undefined;
|
||||||
|
try {
|
||||||
|
return new URL(tileJsonUrl).origin;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** A decoded XYZ cache with bounded workers, reads, retained bytes and request lifetimes. */
|
/** A decoded XYZ cache with bounded workers, reads, retained bytes and request lifetimes. */
|
||||||
export function createVectorTileSource({
|
export function createVectorTileSource({
|
||||||
tileJsonUrl,
|
tileJsonUrl,
|
||||||
template,
|
template,
|
||||||
allowedOrigin,
|
allowedOrigin = tileJsonOrigin(tileJsonUrl),
|
||||||
decode,
|
decode,
|
||||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||||
maxTiles = 16,
|
maxTiles = 16,
|
||||||
|
|||||||
@@ -203,3 +203,45 @@ test('polygon-selected tiles keep nearest-first order and cached revisits issue
|
|||||||
'over-budget selection is rejected before fetching',
|
'over-budget selection is rejected before fetching',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a configured tileJsonUrl sets the tile origin, and a foreign tiles[0] is still refused', async () => {
|
||||||
|
// Without an explicit allowedOrigin the mirror's own TileJSON is honoured…
|
||||||
|
const mirrored = createVectorTileSource({
|
||||||
|
tileJsonUrl: 'http://127.0.0.1:12348/planet',
|
||||||
|
decode: () => [],
|
||||||
|
fetchImpl: async () =>
|
||||||
|
Response.json({ tiles: ['http://127.0.0.1:12348/p/{z}/{x}/{y}.pbf'] }),
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
(await mirrored.getMetadata()).template,
|
||||||
|
'http://127.0.0.1:12348/p/{z}/{x}/{y}.pbf',
|
||||||
|
);
|
||||||
|
|
||||||
|
// …while a TileJSON that names a different host is refused rather than
|
||||||
|
// silently fetched, which is what the origin check is for.
|
||||||
|
const foreign = createVectorTileSource({
|
||||||
|
tileJsonUrl: 'http://127.0.0.1:12348/planet',
|
||||||
|
decode: () => [],
|
||||||
|
fetchImpl: async () =>
|
||||||
|
Response.json({
|
||||||
|
tiles: ['https://tiles.example/planet/{z}/{x}/{y}.pbf'],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await assert.rejects(foreign.getMetadata(), { retryable: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an explicit allowedOrigin still wins over the one derived from tileJsonUrl', async () => {
|
||||||
|
const source = createVectorTileSource({
|
||||||
|
tileJsonUrl: 'http://127.0.0.1:12348/planet',
|
||||||
|
allowedOrigin: 'https://tiles.example',
|
||||||
|
decode: () => [],
|
||||||
|
fetchImpl: async () =>
|
||||||
|
Response.json({
|
||||||
|
tiles: ['https://tiles.example/planet/{z}/{x}/{y}.pbf'],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
(await source.getMetadata()).template,
|
||||||
|
'https://tiles.example/planet/{z}/{x}/{y}.pbf',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -17,6 +17,8 @@ export function createStandaloneApplication({
|
|||||||
cesiumToken,
|
cesiumToken,
|
||||||
geospatial = {},
|
geospatial = {},
|
||||||
voice = {},
|
voice = {},
|
||||||
|
sources = {},
|
||||||
|
streetLevelProviders,
|
||||||
allowQaRegistration = false,
|
allowQaRegistration = false,
|
||||||
}) {
|
}) {
|
||||||
if (constructed)
|
if (constructed)
|
||||||
@@ -47,6 +49,8 @@ export function createStandaloneApplication({
|
|||||||
loaderStatus,
|
loaderStatus,
|
||||||
});
|
});
|
||||||
catalog = createStandaloneCatalog({
|
catalog = createStandaloneCatalog({
|
||||||
|
sources,
|
||||||
|
streetLevelProviders,
|
||||||
nepalBoundaryResolver: (signal) =>
|
nepalBoundaryResolver: (signal) =>
|
||||||
scene.operations.annotationResolver.resolveRegionRingForQuery(
|
scene.operations.annotationResolver.resolveRegionRingForQuery(
|
||||||
'Nepal',
|
'Nepal',
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export { createStandaloneReferenceSources } from './layerSources.js';
|
|||||||
/** Create fresh layer instances using the existing standalone source choices. */
|
/** Create fresh layer instances using the existing standalone source choices. */
|
||||||
export function createStandaloneCatalog({
|
export function createStandaloneCatalog({
|
||||||
nepalBoundaryResolver,
|
nepalBoundaryResolver,
|
||||||
|
sources = {},
|
||||||
|
streetLevelProviders,
|
||||||
signal = new AbortController().signal,
|
signal = new AbortController().signal,
|
||||||
surface = createSurfaceServices({
|
surface = createSurfaceServices({
|
||||||
terrainSource: createApplicationRequestServices().terrain,
|
terrainSource: createApplicationRequestServices().terrain,
|
||||||
@@ -16,7 +18,8 @@ export function createStandaloneCatalog({
|
|||||||
return createApplicationCatalog({
|
return createApplicationCatalog({
|
||||||
nepalBoundaryResolver,
|
nepalBoundaryResolver,
|
||||||
surface,
|
surface,
|
||||||
sources: createStandaloneLayerSources(),
|
sources: createStandaloneLayerSources(sources),
|
||||||
|
streetLevelProviders,
|
||||||
signal,
|
signal,
|
||||||
vesselOptions: {
|
vesselOptions: {
|
||||||
maxRows: import.meta.env?.VITE_AIS_LIVE_MAX_ROWS,
|
maxRows: import.meta.env?.VITE_AIS_LIVE_MAX_ROWS,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { createDirectionsSource } from '../layers/directions/source.js';
|
||||||
|
import { createRecentImagerySource } from '../layers/recentImagery/source.js';
|
||||||
import { createOpenFreeMapSource } from '../sources/openFreeMap.js';
|
import { createOpenFreeMapSource } from '../sources/openFreeMap.js';
|
||||||
import {
|
import {
|
||||||
createFlightSource,
|
createFlightSource,
|
||||||
@@ -22,10 +24,14 @@ import { createReferenceSources } from '../sources/reference.js';
|
|||||||
export { createReferenceSources as createStandaloneReferenceSources } from '../sources/reference.js';
|
export { createReferenceSources as createStandaloneReferenceSources } from '../sources/reference.js';
|
||||||
|
|
||||||
/** Select standalone providers without starting their acquisition. */
|
/** Select standalone providers without starting their acquisition. */
|
||||||
export function createStandaloneLayerSources() {
|
export function createStandaloneLayerSources(overrides = {}) {
|
||||||
|
if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides))
|
||||||
|
throw new TypeError('Source overrides must be an object');
|
||||||
const mapTiles = createOpenFreeMapSource();
|
const mapTiles = createOpenFreeMapSource();
|
||||||
return {
|
const defaults = {
|
||||||
...createReferenceSources(),
|
...createReferenceSources(),
|
||||||
|
directions: createDirectionsSource(),
|
||||||
|
'recent-imagery': createRecentImagerySource(),
|
||||||
flights: createFlightSource(),
|
flights: createFlightSource(),
|
||||||
military: createMilitarySource(),
|
military: createMilitarySource(),
|
||||||
vessels: createVesselSource({
|
vessels: createVesselSource({
|
||||||
@@ -51,4 +57,9 @@ export function createStandaloneLayerSources() {
|
|||||||
token: import.meta.env?.MAPILLARY_CLIENT_TOKEN || '',
|
token: import.meta.env?.MAPILLARY_CLIENT_TOKEN || '',
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
for (const name of Object.keys(overrides)) {
|
||||||
|
if (!Object.hasOwn(defaults, name))
|
||||||
|
throw new TypeError(`Unknown source: ${name}`);
|
||||||
|
}
|
||||||
|
return { ...defaults, ...overrides };
|
||||||
}
|
}
|
||||||
@@ -46,6 +46,9 @@ test('the key gate holds every filter, with the error line outside it', () => {
|
|||||||
assert.match(gate, /data-sl-pano="all"/);
|
assert.match(gate, /data-sl-pano="all"/);
|
||||||
assert.match(gate, /id="sl-since"/);
|
assert.match(gate, /id="sl-since"/);
|
||||||
assert.ok(html.indexOf('id="sl-error"') < start);
|
assert.ok(html.indexOf('id="sl-error"') < start);
|
||||||
|
// The chips stay outside the gate, so their key tooltip is reachable.
|
||||||
|
assert.match(html, /<div id="sl-provider-chips" class="sl-chips"><\/div>/);
|
||||||
|
assert.doesNotMatch(gate, /sl-provider-chips/);
|
||||||
assert.match(html, /id="sl-error"[^>]*role="alert"/);
|
assert.match(html, /id="sl-error"[^>]*role="alert"/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,85 @@
|
|||||||
/** Shared Street Level test stand-ins: Mapillary source, photo viewer, Cesium viewer, ray-casting camera. */
|
/** Shared Street Level test stand-ins: provider snapshot and provider, Mapillary source, photo viewer, Cesium viewer, ray-casting camera. */
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
|
import { MAPILLARY_CREDIT_HTML } from '../layers/streetLevel/policy.js';
|
||||||
|
|
||||||
const RAD = Math.PI / 180;
|
const RAD = Math.PI / 180;
|
||||||
|
|
||||||
|
/** One provider as `getUIState().providers` lists it. */
|
||||||
|
export function providerSnapshot(overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: 'mapillary',
|
||||||
|
name: 'Mapillary',
|
||||||
|
label: 'MAPILLARY',
|
||||||
|
on: true,
|
||||||
|
configured: true,
|
||||||
|
keyRequired: false,
|
||||||
|
requiresKeyId: 'mapillary',
|
||||||
|
loading: false,
|
||||||
|
count: 0,
|
||||||
|
hint: '',
|
||||||
|
error: null,
|
||||||
|
color: '#05cb63',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A Mapillary source for the layer: a configured key, empty tiles, and the
|
* The smallest provider the core accepts. Mutate `stats` to change what
|
||||||
|
* coverageStats() answers; `calls`, `filters` and `context()` record use.
|
||||||
|
*/
|
||||||
|
export function fakeStreetLevelProvider({
|
||||||
|
id = 'mapillary',
|
||||||
|
pickPrefix = 'mly:',
|
||||||
|
html = MAPILLARY_CREDIT_HTML,
|
||||||
|
} = {}) {
|
||||||
|
const calls = { activate: 0, deactivate: 0, mount: 0, open: [], unmount: 0 };
|
||||||
|
const filters = [];
|
||||||
|
const stats = {
|
||||||
|
count: 0,
|
||||||
|
loading: false,
|
||||||
|
hint: '',
|
||||||
|
error: null,
|
||||||
|
keyRequired: false,
|
||||||
|
};
|
||||||
|
let context = null;
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
filters,
|
||||||
|
stats,
|
||||||
|
context: () => context,
|
||||||
|
id,
|
||||||
|
name: 'Mapillary',
|
||||||
|
label: 'MAPILLARY',
|
||||||
|
requiresKeyId: null,
|
||||||
|
pickPrefix,
|
||||||
|
colors: { coverage: '#05cb63' },
|
||||||
|
credit: { html },
|
||||||
|
create: (providerContext) => {
|
||||||
|
context = providerContext;
|
||||||
|
return {
|
||||||
|
status: async () => ({ configured: true }),
|
||||||
|
init() {},
|
||||||
|
activate: () => calls.activate++,
|
||||||
|
deactivate: () => calls.deactivate++,
|
||||||
|
destroy() {},
|
||||||
|
setFilter: (filter) => filters.push(filter),
|
||||||
|
coverageStats: () => ({ ...stats }),
|
||||||
|
handlePick: () => false,
|
||||||
|
viewer: {
|
||||||
|
mount: async () => calls.mount++,
|
||||||
|
open: async (imageId) => calls.open.push(imageId),
|
||||||
|
close() {},
|
||||||
|
unmount: () => calls.unmount++,
|
||||||
|
resize() {},
|
||||||
|
onPose: () => () => {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Mapillary source for the provider: a configured key, empty tiles, and the
|
||||||
* given lookups; `calls` records tile requests.
|
* given lookups; `calls` records tile requests.
|
||||||
*/
|
*/
|
||||||
export function fakeMapillarySource(overrides = {}) {
|
export function fakeMapillarySource(overrides = {}) {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ function request(
|
|||||||
url = '/',
|
url = '/',
|
||||||
body = '',
|
body = '',
|
||||||
origin = 'http://localhost:4173',
|
origin = 'http://localhost:4173',
|
||||||
|
remoteAddress = '127.0.0.1',
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -53,7 +54,7 @@ function request(
|
|||||||
origin,
|
origin,
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
},
|
},
|
||||||
socket: { remoteAddress: '127.0.0.1' },
|
socket: { remoteAddress },
|
||||||
});
|
});
|
||||||
const headers = {};
|
const headers = {};
|
||||||
const res = {
|
const res = {
|
||||||
@@ -199,6 +200,386 @@ test('Realtime handler preserves tools and default instructions, isolates suppli
|
|||||||
assert.equal(sent[0].session.instructions, sent[2].session.instructions);
|
assert.equal(sent[0].session.instructions, sent[2].session.instructions);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Realtime handler keeps API key as default and uses OAuth only when requested', async (t) => {
|
||||||
|
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
|
||||||
|
let oauthReads = 0;
|
||||||
|
const authorizations = [];
|
||||||
|
const routes = install(
|
||||||
|
openAiRealtimeProxy({
|
||||||
|
realtime: {
|
||||||
|
resolveApiKey: () => 'fixture-api-key',
|
||||||
|
resolveOAuthAccessToken: () => {
|
||||||
|
oauthReads += 1;
|
||||||
|
return 'fixture-oauth-token';
|
||||||
|
},
|
||||||
|
fetchImpl: async (_url, options) => {
|
||||||
|
authorizations.push(options.headers.Authorization);
|
||||||
|
return Response.json({ value: 'fixture-ephemeral' });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const token = routes.get('/api/realtime/token');
|
||||||
|
|
||||||
|
const apiResponse = await request(token);
|
||||||
|
assert.equal(apiResponse.status, 200);
|
||||||
|
assert.equal(apiResponse.headers['x-gev-voice-auth'], 'env');
|
||||||
|
assert.equal(oauthReads, 0, 'default cloud voice does not read OAuth');
|
||||||
|
assert.equal(authorizations.at(-1), 'Bearer fixture-api-key');
|
||||||
|
|
||||||
|
const oauthResponse = await request(token, { url: '/?auth=oauth' });
|
||||||
|
assert.equal(oauthResponse.status, 200);
|
||||||
|
assert.equal(oauthResponse.headers['x-gev-voice-auth'], 'codex-oauth');
|
||||||
|
assert.equal(oauthReads, 1);
|
||||||
|
assert.equal(authorizations.at(-1), 'Bearer fixture-oauth-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a Codex OAuth pin refuses API-key minting without silently switching modes', async (t) => {
|
||||||
|
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
|
||||||
|
const authorizations = [];
|
||||||
|
const routes = install(
|
||||||
|
openAiRealtimeProxy({
|
||||||
|
realtime: {
|
||||||
|
environment: { GEV_PREFER_CODEX_OAUTH: 'true' },
|
||||||
|
resolveApiKey: () => assert.fail('pinned OAuth cannot read an API key'),
|
||||||
|
resolveOAuthAccessToken: () => 'fixture-oauth-token',
|
||||||
|
fetchImpl: async (_url, options) => {
|
||||||
|
authorizations.push(options.headers.Authorization);
|
||||||
|
return Response.json({ value: 'fixture-ephemeral' });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const token = routes.get('/api/realtime/token');
|
||||||
|
const refused = await request(token);
|
||||||
|
assert.equal(refused.status, 503);
|
||||||
|
assert.equal(refused.json().code, 'CODEX_OAUTH_REQUIRED');
|
||||||
|
assert.equal(authorizations.length, 0);
|
||||||
|
assert.equal((await request(token, { url: '/?auth=oauth' })).status, 200);
|
||||||
|
assert.deepEqual(authorizations, ['Bearer fixture-oauth-token']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OAuth status and token minting never expose local OAuth to remote clients', async () => {
|
||||||
|
let oauthReads = 0;
|
||||||
|
const routes = install(
|
||||||
|
openAiRealtimeProxy({
|
||||||
|
realtime: {
|
||||||
|
resolveOAuthAccessToken: () => {
|
||||||
|
oauthReads += 1;
|
||||||
|
return 'must-not-be-read-remotely';
|
||||||
|
},
|
||||||
|
fetchImpl: () =>
|
||||||
|
assert.fail('remote OAuth request must not reach OpenAI'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const remote = { remoteAddress: '192.168.1.20' };
|
||||||
|
assert.equal(
|
||||||
|
(await request(routes.get('/api/realtime/oauth-status'), remote)).status,
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await request(routes.get('/api/realtime/token'), {
|
||||||
|
...remote,
|
||||||
|
url: '/?auth=oauth',
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
assert.equal(oauthReads, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OAuth status reports local sign-in availability without returning the token', async () => {
|
||||||
|
const routes = install(
|
||||||
|
openAiRealtimeProxy({
|
||||||
|
realtime: { resolveOAuthAccessToken: () => 'fixture-oauth-secret' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const response = await request(routes.get('/api/realtime/oauth-status'));
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.deepEqual(response.json(), { available: true });
|
||||||
|
assert.equal(response.body.includes('fixture-oauth-secret'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OAuth status and token minting report expired sign-in as re-auth required', async () => {
|
||||||
|
const expired = () => {
|
||||||
|
const error = new Error('ChatGPT sign-in has expired.');
|
||||||
|
error.code = 'CODEX_OAUTH_REAUTH_REQUIRED';
|
||||||
|
throw error;
|
||||||
|
};
|
||||||
|
const routes = install(
|
||||||
|
openAiRealtimeProxy({
|
||||||
|
realtime: {
|
||||||
|
resolveOAuthAccessToken: expired,
|
||||||
|
fetchImpl: () => assert.fail('expired OAuth must not reach OpenAI'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const status = await request(routes.get('/api/realtime/oauth-status'));
|
||||||
|
assert.equal(status.status, 200);
|
||||||
|
assert.deepEqual(status.json(), {
|
||||||
|
available: false,
|
||||||
|
error: 'ChatGPT sign-in has expired.',
|
||||||
|
code: 'CODEX_OAUTH_REAUTH_REQUIRED',
|
||||||
|
reauthRequired: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = await request(routes.get('/api/realtime/token'), {
|
||||||
|
url: '/?auth=oauth',
|
||||||
|
});
|
||||||
|
assert.equal(token.status, 503);
|
||||||
|
assert.equal(token.json().code, 'CODEX_OAUTH_REAUTH_REQUIRED');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OAuth login endpoint shares pending attempts and never exposes credentials', async (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
let starts = 0;
|
||||||
|
let spawnReady;
|
||||||
|
let exit;
|
||||||
|
const plugin = openAiRealtimeProxy({
|
||||||
|
realtime: {
|
||||||
|
resolveOAuthAccessToken: () => {
|
||||||
|
throw new Error('not signed in');
|
||||||
|
},
|
||||||
|
startOAuthLogin: async () => {
|
||||||
|
starts += 1;
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
spawnReady = resolve;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
completion: new Promise((resolve) => {
|
||||||
|
exit = resolve;
|
||||||
|
}),
|
||||||
|
cancel() {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
t.after(plugin.closeBundle);
|
||||||
|
const routes = install(plugin);
|
||||||
|
const handler = routes.get('/api/realtime/oauth-login');
|
||||||
|
|
||||||
|
const firstRequest = request(handler, { method: 'POST' });
|
||||||
|
const concurrent = await request(handler, { method: 'POST' });
|
||||||
|
assert.equal(concurrent.status, 202);
|
||||||
|
assert.equal(concurrent.json().started, false);
|
||||||
|
assert.equal(starts, 1, 'reserve the attempt before awaiting spawn');
|
||||||
|
spawnReady();
|
||||||
|
const started = await firstRequest;
|
||||||
|
assert.equal(started.status, 202);
|
||||||
|
assert.deepEqual(started.json(), {
|
||||||
|
available: false,
|
||||||
|
started: true,
|
||||||
|
pending: true,
|
||||||
|
});
|
||||||
|
assert.equal(starts, 1);
|
||||||
|
|
||||||
|
t.mock.timers.tick(31_000);
|
||||||
|
const duplicate = await request(handler, { method: 'POST' });
|
||||||
|
assert.equal(duplicate.status, 202);
|
||||||
|
assert.equal(duplicate.json().pending, true);
|
||||||
|
assert.equal(starts, 1, 'rapid retries must not spawn another login');
|
||||||
|
|
||||||
|
const remote = await request(handler, {
|
||||||
|
method: 'POST',
|
||||||
|
remoteAddress: '192.168.1.20',
|
||||||
|
});
|
||||||
|
assert.equal(remote.status, 403);
|
||||||
|
assert.equal(starts, 1);
|
||||||
|
|
||||||
|
const crossOrigin = await request(handler, {
|
||||||
|
method: 'POST',
|
||||||
|
origin: 'https://example.com',
|
||||||
|
});
|
||||||
|
assert.equal(crossOrigin.status, 403);
|
||||||
|
assert.equal(starts, 1);
|
||||||
|
|
||||||
|
exit({ exitCode: 1 });
|
||||||
|
await Promise.resolve();
|
||||||
|
const failed = await request(routes.get('/api/realtime/oauth-status'));
|
||||||
|
assert.equal(failed.json().loginFailed, true);
|
||||||
|
assert.equal(failed.json().code, 'CODEX_OAUTH_LOGIN_FAILED');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OAuth status follows process completion, permits retry, and checks credential storage', async (t) => {
|
||||||
|
let token = null;
|
||||||
|
let exit;
|
||||||
|
let starts = 0;
|
||||||
|
const plugin = openAiRealtimeProxy({
|
||||||
|
realtime: {
|
||||||
|
resolveOAuthAccessToken: () => token,
|
||||||
|
startOAuthLogin: async () => {
|
||||||
|
starts += 1;
|
||||||
|
return {
|
||||||
|
completion: new Promise((resolve) => {
|
||||||
|
exit = resolve;
|
||||||
|
}),
|
||||||
|
cancel() {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
t.after(plugin.closeBundle);
|
||||||
|
const routes = install(plugin);
|
||||||
|
const launch = () =>
|
||||||
|
request(routes.get('/api/realtime/oauth-login'), { method: 'POST' });
|
||||||
|
const status = () => request(routes.get('/api/realtime/oauth-status'));
|
||||||
|
await launch();
|
||||||
|
exit({ exitCode: 0 });
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.equal(
|
||||||
|
(await status()).json().code,
|
||||||
|
'CODEX_OAUTH_STORAGE_UNAVAILABLE',
|
||||||
|
'exit success alone is not usable sign-in',
|
||||||
|
);
|
||||||
|
await launch();
|
||||||
|
assert.equal(starts, 2, 'failed attempts can be retried immediately');
|
||||||
|
token = 'fixture-private-token';
|
||||||
|
exit({ exitCode: 0 });
|
||||||
|
await Promise.resolve();
|
||||||
|
const ready = await status();
|
||||||
|
assert.deepEqual(ready.json(), { available: true });
|
||||||
|
assert.equal(ready.body.includes(token), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OAuth login timeout and server shutdown cancel the owned process', async () => {
|
||||||
|
let timeout;
|
||||||
|
let cancelled = 0;
|
||||||
|
let cleared = 0;
|
||||||
|
const plugin = openAiRealtimeProxy({
|
||||||
|
realtime: {
|
||||||
|
resolveOAuthAccessToken: () => null,
|
||||||
|
startOAuthLogin: async () => ({
|
||||||
|
completion: new Promise(() => {}),
|
||||||
|
cancel() {
|
||||||
|
cancelled += 1;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
setTimer(callback, duration) {
|
||||||
|
timeout = callback;
|
||||||
|
assert.equal(duration, 120_000);
|
||||||
|
return 1;
|
||||||
|
},
|
||||||
|
clearTimer() {
|
||||||
|
cleared += 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const routes = install(plugin);
|
||||||
|
const launch = () =>
|
||||||
|
request(routes.get('/api/realtime/oauth-login'), { method: 'POST' });
|
||||||
|
await launch();
|
||||||
|
timeout();
|
||||||
|
assert.equal(cancelled, 1);
|
||||||
|
const status = await request(routes.get('/api/realtime/oauth-status'));
|
||||||
|
assert.equal(status.json().code, 'CODEX_OAUTH_LOGIN_TIMEOUT');
|
||||||
|
await launch();
|
||||||
|
plugin.closeBundle();
|
||||||
|
plugin.closeBundle();
|
||||||
|
assert.equal(cancelled, 2, 'shutdown is idempotent');
|
||||||
|
assert.equal(cleared, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OAuth launch errors are safe and cross-origin status requests cannot read credentials', async () => {
|
||||||
|
let reads = 0;
|
||||||
|
const routes = install(
|
||||||
|
openAiRealtimeProxy({
|
||||||
|
realtime: {
|
||||||
|
resolveOAuthAccessToken() {
|
||||||
|
reads += 1;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
startOAuthLogin: async () => {
|
||||||
|
throw new Error('private-token-fixture');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const remote = await request(routes.get('/api/realtime/oauth-status'), {
|
||||||
|
origin: 'https://example.com',
|
||||||
|
});
|
||||||
|
assert.equal(remote.status, 403);
|
||||||
|
assert.equal(reads, 0);
|
||||||
|
const failed = await request(routes.get('/api/realtime/oauth-login'), {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
assert.equal(failed.status, 503);
|
||||||
|
assert.equal(failed.json().code, 'CODEX_OAUTH_LOGIN_FAILED');
|
||||||
|
assert.equal(failed.body.includes('private-token-fixture'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a login that spawns after timeout is cancelled and cannot replace a retry', async (t) => {
|
||||||
|
let timeout;
|
||||||
|
let releaseSpawn;
|
||||||
|
let cancelled = 0;
|
||||||
|
let launches = 0;
|
||||||
|
const plugin = openAiRealtimeProxy({
|
||||||
|
realtime: {
|
||||||
|
resolveOAuthAccessToken: () => null,
|
||||||
|
startOAuthLogin: async () => {
|
||||||
|
launches += 1;
|
||||||
|
if (launches === 1)
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
releaseSpawn = resolve;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
completion: new Promise(() => {}),
|
||||||
|
cancel() {
|
||||||
|
cancelled += 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
setTimer(callback) {
|
||||||
|
timeout = callback;
|
||||||
|
return 1;
|
||||||
|
},
|
||||||
|
clearTimer() {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
t.after(plugin.closeBundle);
|
||||||
|
const routes = install(plugin);
|
||||||
|
const launch = () =>
|
||||||
|
request(routes.get('/api/realtime/oauth-login'), { method: 'POST' });
|
||||||
|
const first = launch();
|
||||||
|
timeout();
|
||||||
|
await launch();
|
||||||
|
releaseSpawn();
|
||||||
|
await first;
|
||||||
|
assert.equal(launches, 2);
|
||||||
|
assert.equal(cancelled, 1, 'late process must not become an orphan');
|
||||||
|
const status = (
|
||||||
|
await request(routes.get('/api/realtime/oauth-status'))
|
||||||
|
).json();
|
||||||
|
assert.equal(status.pending, true);
|
||||||
|
assert.equal(
|
||||||
|
status.loginFailed,
|
||||||
|
undefined,
|
||||||
|
'the older attempt cannot fail the retry',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OAuth login endpoint selects an existing local sign-in without launching Codex', async () => {
|
||||||
|
const handler = install(
|
||||||
|
openAiRealtimeProxy({
|
||||||
|
realtime: {
|
||||||
|
resolveOAuthAccessToken: () => 'fixture-existing-secret',
|
||||||
|
startOAuthLogin: () =>
|
||||||
|
assert.fail('existing auth must not launch login'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).get('/api/realtime/oauth-login');
|
||||||
|
const response = await request(handler, { method: 'POST' });
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.deepEqual(response.json(), {
|
||||||
|
available: true,
|
||||||
|
started: false,
|
||||||
|
pending: false,
|
||||||
|
});
|
||||||
|
assert.equal(response.body.includes('fixture-existing-secret'), false);
|
||||||
|
});
|
||||||
|
|
||||||
test('caption transcription is on by default, reported to the client meter, and can be turned off', async (t) => {
|
test('caption transcription is on by default, reported to the client meter, and can be turned off', async (t) => {
|
||||||
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
|
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
|
||||||
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
|
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
|
||||||
|
|||||||
@@ -30,6 +30,27 @@ test('TLE text parses into named entries with catalog numbers', () => {
|
|||||||
assert.equal(tleCatalogNumber('1 xx'), null);
|
assert.equal(tleCatalogNumber('1 xx'), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const missingLine of [1, 2]) {
|
||||||
|
test(`TLE parsing resumes after a set missing line ${missingLine}`, () => {
|
||||||
|
const sets = Array.from({ length: 8 }, (_, i) => {
|
||||||
|
const lines = TLE.trim().split('\n').slice(0, 3);
|
||||||
|
lines[0] = `SAT ${i}`;
|
||||||
|
return lines;
|
||||||
|
});
|
||||||
|
sets[1].splice(missingLine, 1);
|
||||||
|
const entries = parseTleText(sets.flat().join('\r\n'));
|
||||||
|
assert.deepEqual(
|
||||||
|
entries.map((entry) => entry.name),
|
||||||
|
['SAT 0', 'SAT 2', 'SAT 3', 'SAT 4', 'SAT 5', 'SAT 6', 'SAT 7'],
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
entries.every(
|
||||||
|
(entry) => entry.line1.startsWith('1 ') && entry.line2.startsWith('2 '),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test('the next pass defaults to the ISS and reports times, peak and direction', async () => {
|
test('the next pass defaults to the ISS and reports times, peak and direction', async () => {
|
||||||
const catalog = composeCatalog({
|
const catalog = composeCatalog({
|
||||||
tools: coreTools,
|
tools: coreTools,
|
||||||
|
|||||||
+15
-5
@@ -588,7 +588,12 @@ export class LayerPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_syncToggleButton(button, layer) {
|
_syncToggleButton(button, layer) {
|
||||||
const feedState = layer.enabled ? layerFeedState(layer.stats) : 'off';
|
const unavailable = layer.stats?.sourceUnavailable === true;
|
||||||
|
const feedState = unavailable
|
||||||
|
? 'unavailable'
|
||||||
|
: layer.enabled
|
||||||
|
? layerFeedState(layer.stats)
|
||||||
|
: 'off';
|
||||||
const transitioning =
|
const transitioning =
|
||||||
layer.lifecycleState === 'enabling' ||
|
layer.lifecycleState === 'enabling' ||
|
||||||
layer.lifecycleState === 'disabling';
|
layer.lifecycleState === 'disabling';
|
||||||
@@ -601,7 +606,7 @@ export class LayerPanel {
|
|||||||
for (const state of Object.keys(FEED_STATE_LABELS)) {
|
for (const state of Object.keys(FEED_STATE_LABELS)) {
|
||||||
button.classList.toggle(
|
button.classList.toggle(
|
||||||
`feed-${state}`,
|
`feed-${state}`,
|
||||||
layer.enabled && !uncertain && feedState === state,
|
(layer.enabled || unavailable) && !uncertain && feedState === state,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
button.dataset.feedState = transitioning
|
button.dataset.feedState = transitioning
|
||||||
@@ -613,16 +618,21 @@ export class LayerPanel {
|
|||||||
// click guard above prevents repeat activation without the focus loss caused
|
// click guard above prevents repeat activation without the focus loss caused
|
||||||
// by native `disabled`.
|
// by native `disabled`.
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
button.setAttribute('aria-disabled', String(transitioning));
|
button.setAttribute(
|
||||||
|
'aria-disabled',
|
||||||
|
String(transitioning || (unavailable && !layer.enabled)),
|
||||||
|
);
|
||||||
button.setAttribute('aria-busy', String(transitioning));
|
button.setAttribute('aria-busy', String(transitioning));
|
||||||
button.textContent = transitioning
|
button.textContent = transitioning
|
||||||
? layer.lifecycleState.toUpperCase()
|
? layer.lifecycleState.toUpperCase()
|
||||||
: uncertain
|
: uncertain
|
||||||
? 'UNCERTAIN'
|
? 'UNCERTAIN'
|
||||||
: layer.enabled
|
: layer.enabled || unavailable
|
||||||
? FEED_STATE_LABELS[feedState]
|
? FEED_STATE_LABELS[feedState]
|
||||||
: 'OFF';
|
: 'OFF';
|
||||||
const keyGuidance = layerKeyRequirementTooltip(layer);
|
const keyGuidance = unavailable
|
||||||
|
? String(layer.stats.error || 'Data source unavailable')
|
||||||
|
: layerKeyRequirementTooltip(layer);
|
||||||
// Name the missing key on the control itself: a row reading KEY REQUIRED
|
// Name the missing key on the control itself: a row reading KEY REQUIRED
|
||||||
// without saying WHICH key leaves a dead control and no next step. Empty
|
// without saying WHICH key leaves a dead control and no next step. Empty
|
||||||
// when the layer needs no key, or already has one.
|
// when the layer needs no key, or already has one.
|
||||||
|
|||||||
@@ -228,3 +228,33 @@ test('the Recent Imagery readout mounts in its rail body like the weather readou
|
|||||||
globalThis.document = previousDocument;
|
globalThis.document = previousDocument;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an omitted source is visibly unavailable and cannot be toggled on', async () => {
|
||||||
|
const { LayerPanel } = await import('./layerPanel.js');
|
||||||
|
const attrs = new Map();
|
||||||
|
const button = {
|
||||||
|
classList: { toggle() {} },
|
||||||
|
dataset: {},
|
||||||
|
setAttribute: (key, value) => attrs.set(key, value),
|
||||||
|
};
|
||||||
|
const layer = {
|
||||||
|
id: 'flights',
|
||||||
|
name: 'Flights',
|
||||||
|
enabled: false,
|
||||||
|
stats: {
|
||||||
|
sourceUnavailable: true,
|
||||||
|
status: 'unavailable',
|
||||||
|
error: 'Data source not configured: flights',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
LayerPanel.prototype._syncToggleButton(button, layer);
|
||||||
|
assert.equal(button.textContent, 'UNAVAILABLE');
|
||||||
|
assert.equal(attrs.get('aria-disabled'), 'true');
|
||||||
|
assert.match(button.title, /not configured/);
|
||||||
|
LayerPanel.prototype._syncToggleButton(button, { ...layer, enabled: true });
|
||||||
|
assert.equal(
|
||||||
|
attrs.get('aria-disabled'),
|
||||||
|
'false',
|
||||||
|
'an active layer can always be switched off',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { isExplicitLayerStateOrigin } from '../data/layerState.js';
|
import { isExplicitLayerStateOrigin } from '../data/layerState.js';
|
||||||
|
import { syncChipGroup } from './chipGroup.js';
|
||||||
import {
|
import {
|
||||||
presentStreetLevelPanel,
|
presentStreetLevelPanel,
|
||||||
SINCE_STOPS,
|
SINCE_STOPS,
|
||||||
@@ -24,8 +25,8 @@ const setAttr = (node, key, value) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fills the Street Level panel body: filters and viewer. The panel chrome
|
* Fills the Street Level panel body: provider chips, filters and viewer. The
|
||||||
* itself belongs to the application shell.
|
* panel chrome itself belongs to the application shell.
|
||||||
*/
|
*/
|
||||||
export class StreetLevelControls {
|
export class StreetLevelControls {
|
||||||
constructor({ root, layer, actions }) {
|
constructor({ root, layer, actions }) {
|
||||||
@@ -36,6 +37,7 @@ export class StreetLevelControls {
|
|||||||
this.listeners = new AbortController();
|
this.listeners = new AbortController();
|
||||||
this._unsubscribe = null;
|
this._unsubscribe = null;
|
||||||
this._state = null;
|
this._state = null;
|
||||||
|
this._view = null;
|
||||||
this._wasEnabled = null;
|
this._wasEnabled = null;
|
||||||
this._wasOpen = false;
|
this._wasOpen = false;
|
||||||
// Whether the last switch-on was explicit (user, voice, tool), not a restore.
|
// Whether the last switch-on was explicit (user, voice, tool), not a restore.
|
||||||
@@ -53,6 +55,7 @@ export class StreetLevelControls {
|
|||||||
];
|
];
|
||||||
return {
|
return {
|
||||||
status: byId('sl-status'),
|
status: byId('sl-status'),
|
||||||
|
providerChips: byId('sl-provider-chips'),
|
||||||
controls: byId('sl-controls'),
|
controls: byId('sl-controls'),
|
||||||
error: byId('sl-error'),
|
error: byId('sl-error'),
|
||||||
errorText: byId('sl-error-text'),
|
errorText: byId('sl-error-text'),
|
||||||
@@ -85,6 +88,17 @@ export class StreetLevelControls {
|
|||||||
this.layer.attachViewerHost?.(el.viewer);
|
this.layer.attachViewerHost?.(el.viewer);
|
||||||
|
|
||||||
this.listen(el.status, 'click', () => this._toggleEnabled());
|
this.listen(el.status, 'click', () => this._toggleEnabled());
|
||||||
|
this.listen(el.providerChips, 'click', (event) => {
|
||||||
|
const button = event.target?.closest?.('.data-toggle-chip');
|
||||||
|
if (!button || button.disabled) return;
|
||||||
|
// Without a key the chips stay focusable for their tooltip; a click
|
||||||
|
// says the same thing rather than switching on a layer that cannot draw.
|
||||||
|
if (this._view?.controlsDisabled) {
|
||||||
|
if (button.title) this.actions.showToast?.(button.title);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._toggleProvider(button.dataset.chipId);
|
||||||
|
});
|
||||||
for (const button of el.panoButtons) {
|
for (const button of el.panoButtons) {
|
||||||
this.listen(button, 'click', () =>
|
this.listen(button, 'click', () =>
|
||||||
this._setParams({ pano: button.dataset.slPano }),
|
this._setParams({ pano: button.dataset.slPano }),
|
||||||
@@ -192,12 +206,6 @@ export class StreetLevelControls {
|
|||||||
|
|
||||||
async _toggleEnabled() {
|
async _toggleEnabled() {
|
||||||
const enabled = this.actions.isEnabled?.() === true;
|
const enabled = this.actions.isEnabled?.() === true;
|
||||||
// The pill is the only switch: it also brings back Mapillary that a link
|
|
||||||
// or tool switched off under a layer that is on.
|
|
||||||
if (this._state?.providerOn === false) {
|
|
||||||
this._setParams({ mapillary: true });
|
|
||||||
if (enabled) return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await this.actions.setEnabled?.(!enabled);
|
await this.actions.setEnabled?.(!enabled);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -205,6 +213,45 @@ export class StreetLevelControls {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _ensureEnabled() {
|
||||||
|
if (this.actions.isEnabled?.()) return true;
|
||||||
|
try {
|
||||||
|
await this.actions.setEnabled?.(true);
|
||||||
|
} catch (error) {
|
||||||
|
this.actions.showToast?.(
|
||||||
|
error?.message || 'Street Level could not start',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return this.actions.isEnabled?.() === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lighting a chip turns the layer on; darkening the last lit one turns the
|
||||||
|
* layer off but keeps the provider on, so the layer comes back with it.
|
||||||
|
*/
|
||||||
|
async _toggleProvider(providerId) {
|
||||||
|
const chip = this._view?.providers.find((entry) => entry.id === providerId);
|
||||||
|
if (!chip) return;
|
||||||
|
if (!chip.active) {
|
||||||
|
this._setParams({ [providerId]: true });
|
||||||
|
await this._ensureEnabled();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const othersLit = this._view.providers.some(
|
||||||
|
(entry) => entry.id !== providerId && entry.active,
|
||||||
|
);
|
||||||
|
if (othersLit) {
|
||||||
|
this._setParams({ [providerId]: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.actions.setEnabled?.(false);
|
||||||
|
} catch (error) {
|
||||||
|
this.actions.showToast?.(error?.message || 'Street Level toggle failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Through the data manager, so saved state and share links record it. */
|
/** Through the data manager, so saved state and share links record it. */
|
||||||
_setParams(params) {
|
_setParams(params) {
|
||||||
this.actions.setParams(params, { origin: 'user' });
|
this.actions.setParams(params, { origin: 'user' });
|
||||||
@@ -315,8 +362,10 @@ export class StreetLevelControls {
|
|||||||
if (this.destroyed || !state || !this.root) return;
|
if (this.destroyed || !state || !this.root) return;
|
||||||
this._state = state;
|
this._state = state;
|
||||||
const view = presentStreetLevelPanel(state);
|
const view = presentStreetLevelPanel(state);
|
||||||
|
this._view = view;
|
||||||
this._renderHeader(view);
|
this._renderHeader(view);
|
||||||
setProp(this._elements.controls, 'disabled', view.controlsDisabled);
|
setProp(this._elements.controls, 'disabled', view.controlsDisabled);
|
||||||
|
syncChipGroup(this._elements.providerChips, view.providers);
|
||||||
this._renderError(view);
|
this._renderError(view);
|
||||||
this._renderFilters(view);
|
this._renderFilters(view);
|
||||||
this._renderViewer(view);
|
this._renderViewer(view);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { StreetLevelControls } from './streetLevelControls.js';
|
import { StreetLevelControls } from './streetLevelControls.js';
|
||||||
import { createStreetLevelLayer } from '../layers/streetLevel/index.js';
|
import { createStreetLevelLayer } from '../layers/streetLevel/index.js';
|
||||||
|
import { createMapillaryProvider } from '../layers/streetLevel/mapillaryProvider.js';
|
||||||
import { DataLayerManager } from '../data/manager.js';
|
import { DataLayerManager } from '../data/manager.js';
|
||||||
import { LayerStateCoordinator } from '../data/layerStateCoordinator.js';
|
import { LayerStateCoordinator } from '../data/layerStateCoordinator.js';
|
||||||
import {
|
import {
|
||||||
@@ -9,7 +10,10 @@ import {
|
|||||||
REGISTERED_LAYER_IDS,
|
REGISTERED_LAYER_IDS,
|
||||||
encodeLayerStateParams,
|
encodeLayerStateParams,
|
||||||
} from '../data/layerState.js';
|
} from '../data/layerState.js';
|
||||||
import { fakeMapillarySource } from '../testSupport/streetLevelFakes.mjs';
|
import {
|
||||||
|
fakeMapillarySource,
|
||||||
|
providerSnapshot,
|
||||||
|
} from '../testSupport/streetLevelFakes.mjs';
|
||||||
|
|
||||||
/* ── A small DOM: just what the panel controls touch ───────────────────── */
|
/* ── A small DOM: just what the panel controls touch ───────────────────── */
|
||||||
|
|
||||||
@@ -94,6 +98,23 @@ class FakeNode {
|
|||||||
if (current === this) return true;
|
if (current === this) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
closest(selector) {
|
||||||
|
for (let current = this; current; current = current.parent)
|
||||||
|
if (current.matches?.(selector)) return current;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
insertBefore(child, before) {
|
||||||
|
child.parent?.children.splice(child.parent.children.indexOf(child), 1);
|
||||||
|
child.parent = this;
|
||||||
|
const at = before ? this.children.indexOf(before) : -1;
|
||||||
|
if (at === -1) this.children.push(child);
|
||||||
|
else this.children.splice(at, 0, child);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
remove() {
|
||||||
|
this.parent?.children.splice(this.parent.children.indexOf(this), 1);
|
||||||
|
this.parent = null;
|
||||||
|
}
|
||||||
focus() {
|
focus() {
|
||||||
this.ownerDocument.activeElement = this;
|
this.ownerDocument.activeElement = this;
|
||||||
}
|
}
|
||||||
@@ -182,6 +203,7 @@ function panelDom({ fullscreen = true, popover = false } = {}) {
|
|||||||
const add = (parent, tag, options) =>
|
const add = (parent, tag, options) =>
|
||||||
parent.appendChild(new FakeNode(document, tag, options));
|
parent.appendChild(new FakeNode(document, tag, options));
|
||||||
add(root, 'button', { id: 'sl-status' });
|
add(root, 'button', { id: 'sl-status' });
|
||||||
|
add(root, 'div', { id: 'sl-provider-chips' });
|
||||||
const wrap = add(root, 'div', { id: 'sl-viewer-wrap' });
|
const wrap = add(root, 'div', { id: 'sl-viewer-wrap' });
|
||||||
if (fullscreen) wrap.requestFullscreen = async () => fullscreenTo(wrap);
|
if (fullscreen) wrap.requestFullscreen = async () => fullscreenTo(wrap);
|
||||||
if (popover) {
|
if (popover) {
|
||||||
@@ -280,7 +302,9 @@ const memoryStorage = () => {
|
|||||||
|
|
||||||
/** The production path: controls → dataManager → layer → durable layer state. */
|
/** The production path: controls → dataManager → layer → durable layer state. */
|
||||||
async function productionPanel(dom) {
|
async function productionPanel(dom) {
|
||||||
const layer = createStreetLevelLayer({ source: fakeMapillarySource() });
|
const layer = createStreetLevelLayer({
|
||||||
|
providers: [createMapillaryProvider({ source: fakeMapillarySource() })],
|
||||||
|
});
|
||||||
const manager = new DataLayerManager({});
|
const manager = new DataLayerManager({});
|
||||||
for (const id of REGISTERED_LAYER_IDS)
|
for (const id of REGISTERED_LAYER_IDS)
|
||||||
manager.register(id === 'street-level' ? layer : plainLayer(id));
|
manager.register(id === 'street-level' ? layer : plainLayer(id));
|
||||||
@@ -390,7 +414,7 @@ function uiState({
|
|||||||
} = {}) {
|
} = {}) {
|
||||||
return {
|
return {
|
||||||
enabled,
|
enabled,
|
||||||
providerOn: on,
|
providers: [providerSnapshot({ on, keyRequired })],
|
||||||
keyRequired,
|
keyRequired,
|
||||||
keyRejected: false,
|
keyRejected: false,
|
||||||
filter: { pano, sinceDays: 0 },
|
filter: { pano, sinceDays: 0 },
|
||||||
@@ -408,7 +432,7 @@ function uiState({
|
|||||||
|
|
||||||
function stubPanel(dom, state, extraActions = {}) {
|
function stubPanel(dom, state, extraActions = {}) {
|
||||||
const layer = stubLayer();
|
const layer = stubLayer();
|
||||||
const calls = { setParams: [], setEnabled: [], collapsed: [] };
|
const calls = { setParams: [], setEnabled: [], collapsed: [], toasts: [] };
|
||||||
let enabled = state.enabled;
|
let enabled = state.enabled;
|
||||||
/** Reports a switch-on request's origin, as the data manager does. */
|
/** Reports a switch-on request's origin, as the data manager does. */
|
||||||
let announce = null;
|
let announce = null;
|
||||||
@@ -425,7 +449,7 @@ function stubPanel(dom, state, extraActions = {}) {
|
|||||||
setParams: (params, options) => calls.setParams.push([params, options]),
|
setParams: (params, options) => calls.setParams.push([params, options]),
|
||||||
setPanelCollapsed: (collapsed, options) =>
|
setPanelCollapsed: (collapsed, options) =>
|
||||||
calls.collapsed.push([collapsed, options]),
|
calls.collapsed.push([collapsed, options]),
|
||||||
showToast() {},
|
showToast: (message) => calls.toasts.push(message),
|
||||||
subscribeEnableRequests: (listener) => {
|
subscribeEnableRequests: (listener) => {
|
||||||
announce = listener;
|
announce = listener;
|
||||||
return () => {
|
return () => {
|
||||||
@@ -442,44 +466,66 @@ function stubPanel(dom, state, extraActions = {}) {
|
|||||||
controls,
|
controls,
|
||||||
announce: (origin) => announce?.(origin),
|
announce: (origin) => announce?.(origin),
|
||||||
subscribed: () => announce !== null,
|
subscribed: () => announce !== null,
|
||||||
|
chip: () => dom.root.querySelector('#sl-provider-chips').children[0],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test('the header pill switches the layer, bringing back Mapillary switched off elsewhere', () =>
|
test('the header pill switches the layer; provider switches are left alone', () =>
|
||||||
withDom(async (dom) => {
|
withDom(async (dom) => {
|
||||||
const status = dom.root.querySelector('#sl-status');
|
const status = dom.root.querySelector('#sl-status');
|
||||||
const on = stubPanel(dom, uiState());
|
const { calls, controls } = stubPanel(dom, uiState());
|
||||||
status.click();
|
status.click();
|
||||||
await settle();
|
await settle();
|
||||||
assert.deepEqual(on.calls.setEnabled, [false]);
|
assert.deepEqual(calls.setEnabled, [false]);
|
||||||
assert.deepEqual(on.calls.setParams, [], 'switching off keeps the switch');
|
assert.deepEqual(calls.setParams, []);
|
||||||
on.controls.destroy();
|
controls.destroy();
|
||||||
const dark = stubPanel(dom, uiState({ enabled: false, on: false }));
|
|
||||||
status.click();
|
|
||||||
await settle();
|
|
||||||
assert.deepEqual(dark.calls.setParams, [
|
|
||||||
[{ mapillary: true }, { origin: 'user' }],
|
|
||||||
]);
|
|
||||||
assert.deepEqual(dark.calls.setEnabled, [true]);
|
|
||||||
dark.controls.destroy();
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
test('a layer that is on with Mapillary switched off reads OFF, and the pill brings Mapillary back', () =>
|
test('darkening the only lit chip turns the layer off and keeps the provider switched on', () =>
|
||||||
withDom(async (dom) => {
|
withDom(async (dom) => {
|
||||||
const status = dom.root.querySelector('#sl-status');
|
const { calls, chip, controls } = stubPanel(dom, uiState());
|
||||||
const { calls, controls } = stubPanel(dom, uiState({ on: false }));
|
assert.equal(chip().dataset.chipId, 'mapillary');
|
||||||
assert.equal(status.textContent, 'OFF');
|
assert.equal(chip().getAttribute('aria-pressed'), 'true');
|
||||||
assert.equal(status.getAttribute('aria-pressed'), 'false');
|
chip().click();
|
||||||
assert.match(
|
await settle();
|
||||||
dom.root.querySelector('#sl-coverage-meta').textContent,
|
assert.deepEqual(calls.setEnabled, [false]);
|
||||||
/Mapillary is off in this view/,
|
assert.deepEqual(calls.setParams, [], 'the provider switch is untouched');
|
||||||
|
controls.destroy();
|
||||||
|
}));
|
||||||
|
|
||||||
|
test('lighting a dark chip switches the provider on as a user params request, then the layer', () =>
|
||||||
|
withDom(async (dom) => {
|
||||||
|
const { calls, chip, controls } = stubPanel(
|
||||||
|
dom,
|
||||||
|
uiState({ enabled: false, on: false }),
|
||||||
);
|
);
|
||||||
status.click();
|
assert.equal(chip().getAttribute('aria-pressed'), 'false');
|
||||||
|
chip().click();
|
||||||
await settle();
|
await settle();
|
||||||
assert.deepEqual(calls.setParams, [
|
assert.deepEqual(calls.setParams, [
|
||||||
[{ mapillary: true }, { origin: 'user' }],
|
[{ mapillary: true }, { origin: 'user' }],
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(calls.setEnabled, [], 'the layer itself stays on');
|
assert.deepEqual(calls.setEnabled, [true]);
|
||||||
|
controls.destroy();
|
||||||
|
}));
|
||||||
|
|
||||||
|
test('under KEY REQUIRED the provider chip stays live and explains the key instead of toggling', () =>
|
||||||
|
withDom(async (dom) => {
|
||||||
|
const { calls, chip, controls } = stubPanel(
|
||||||
|
dom,
|
||||||
|
uiState({ keyRequired: true }),
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
dom.root.querySelector('#sl-controls').contains(chip()),
|
||||||
|
false,
|
||||||
|
'the chip is outside the disabled fieldset',
|
||||||
|
);
|
||||||
|
chip().click();
|
||||||
|
await settle();
|
||||||
|
assert.deepEqual(calls.setEnabled, []);
|
||||||
|
assert.deepEqual(calls.setParams, []);
|
||||||
|
assert.equal(calls.toasts.length, 1);
|
||||||
|
assert.match(calls.toasts[0], /^Mapillary: Needs /);
|
||||||
controls.destroy();
|
controls.destroy();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -54,11 +54,8 @@ function formatDate(ms) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether the layer draws: on, and Mapillary not switched off by a link or tool. */
|
|
||||||
const drawing = (state) => state.enabled === true && state.providerOn !== false;
|
|
||||||
|
|
||||||
function presentStatus(state) {
|
function presentStatus(state) {
|
||||||
const pressed = drawing(state);
|
const pressed = state.enabled === true;
|
||||||
const title = pressed ? 'Turn Street Level off' : 'Turn Street Level on';
|
const title = pressed ? 'Turn Street Level off' : 'Turn Street Level on';
|
||||||
const key = keyStatusLabel(state);
|
const key = keyStatusLabel(state);
|
||||||
if (key) return { text: key, tone: 'warn', pressed, title };
|
if (key) return { text: key, tone: 'warn', pressed, title };
|
||||||
@@ -69,10 +66,44 @@ function presentStatus(state) {
|
|||||||
: { text: 'OFF', tone: '', pressed, title };
|
: { text: 'OFF', tone: '', pressed, title };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How to add the missing Mapillary key. */
|
/** A chip is lit only while the layer and its provider are both on. */
|
||||||
function keyHint() {
|
function presentProviders(state) {
|
||||||
const requirement = keySetupRequirement('mapillary');
|
const enabled = state.enabled === true;
|
||||||
return requirement ? `Mapillary: ${requirement}` : null;
|
return (state.providers || []).map((provider) => {
|
||||||
|
const keyRequired = provider.keyRequired === true;
|
||||||
|
const on = enabled && provider.on === true;
|
||||||
|
let title = `${provider.name} imagery ${on ? 'on' : 'off'}`;
|
||||||
|
if (provider.keyRejected && provider.error)
|
||||||
|
title = `${provider.name}: ${provider.error}`;
|
||||||
|
else if (keyRequired && provider.requiresKeyId)
|
||||||
|
title = `${provider.name}: ${keySetupRequirement(provider.requiresKeyId)}`;
|
||||||
|
else if (provider.error) title = `${provider.name}: ${provider.error}`;
|
||||||
|
return {
|
||||||
|
id: provider.id,
|
||||||
|
label: provider.label,
|
||||||
|
color: provider.color || null,
|
||||||
|
title,
|
||||||
|
active: on,
|
||||||
|
disabled: false,
|
||||||
|
state: keyRequired
|
||||||
|
? 'error'
|
||||||
|
: on && provider.loading
|
||||||
|
? 'loading'
|
||||||
|
: on
|
||||||
|
? 'active'
|
||||||
|
: 'idle',
|
||||||
|
busy: on && provider.loading === true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How to add the key the first switched-on provider is missing. */
|
||||||
|
function keyHint(state) {
|
||||||
|
const provider = (state.providers || []).find(
|
||||||
|
(p) => p.on && p.keyRequired && p.requiresKeyId,
|
||||||
|
);
|
||||||
|
const requirement = provider && keySetupRequirement(provider.requiresKeyId);
|
||||||
|
return requirement ? `${provider.name}: ${requirement}` : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function presentViewer(state) {
|
function presentViewer(state) {
|
||||||
@@ -89,14 +120,16 @@ function presentViewer(state) {
|
|||||||
captionLeft: street.creator ? `Image by ${street.creator}` : '',
|
captionLeft: street.creator ? `Image by ${street.creator}` : '',
|
||||||
captionRight: right.join(' · '),
|
captionRight: right.join(' · '),
|
||||||
link: street.externalUrl || null,
|
link: street.externalUrl || null,
|
||||||
linkLabel: street.externalUrl ? 'MAPILLARY ↗' : '',
|
linkLabel: street.providerLabel ? `${street.providerLabel} ↗` : '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function presentMeta(state) {
|
function presentMeta(state) {
|
||||||
|
const providers = state.providers || [];
|
||||||
|
if (providers.length === 0) return state.coverage.hint || '';
|
||||||
if (!state.enabled) return 'Switch Street Level on to draw its coverage.';
|
if (!state.enabled) return 'Switch Street Level on to draw its coverage.';
|
||||||
if (!drawing(state))
|
if (!providers.some((p) => p.on))
|
||||||
return 'Mapillary is off in this view. Switch Street Level on to show it.';
|
return 'Every provider is switched off: light a chip to draw its coverage.';
|
||||||
if (state.sequence.selectedId)
|
if (state.sequence.selectedId)
|
||||||
return state.sequence.loading
|
return state.sequence.loading
|
||||||
? 'Loading this sequence…'
|
? 'Loading this sequence…'
|
||||||
@@ -110,20 +143,23 @@ function presentMeta(state) {
|
|||||||
const filter = state.filter || {};
|
const filter = state.filter || {};
|
||||||
return (filter.pano || 'all') !== 'all' || Number(filter.sinceDays) > 0
|
return (filter.pano || 'all') !== 'all' || Number(filter.sinceDays) > 0
|
||||||
? 'No sequences in view match the filter'
|
? 'No sequences in view match the filter'
|
||||||
: 'No Mapillary coverage in view';
|
: 'No coverage in view';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {{now?: number}} [options] Clock for the SINCE readout. */
|
/** @param {{now?: number}} [options] Clock for the SINCE readout. */
|
||||||
export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
|
export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
|
||||||
|
const keyRequired = state.keyRequired === true;
|
||||||
const filter = state.filter || { pano: 'all', sinceDays: 0 };
|
const filter = state.filter || { pano: 'all', sinceDays: 0 };
|
||||||
// A missing key says how to add it; a rejected key's own error names the fix.
|
// A missing key says how to add it; a rejected key's own error names the fix.
|
||||||
const keyMissing = state.keyRequired === true && state.keyRejected !== true;
|
const keyMissing = keyRequired && state.keyRejected !== true;
|
||||||
return {
|
return {
|
||||||
enabled: state.enabled === true,
|
enabled: state.enabled === true,
|
||||||
|
keyRequired,
|
||||||
status: presentStatus(state),
|
status: presentStatus(state),
|
||||||
controlsDisabled: state.keyRequired === true,
|
controlsDisabled: keyRequired || (state.providers || []).length === 0,
|
||||||
|
providers: presentProviders(state),
|
||||||
error: keyMissing
|
error: keyMissing
|
||||||
? keyHint()
|
? keyHint(state)
|
||||||
: state.street.error ||
|
: state.street.error ||
|
||||||
state.sequence.error ||
|
state.sequence.error ||
|
||||||
state.coverage.error ||
|
state.coverage.error ||
|
||||||
|
|||||||
@@ -4,10 +4,36 @@ import {
|
|||||||
presentStreetLevelPanel,
|
presentStreetLevelPanel,
|
||||||
sinceStopIndex,
|
sinceStopIndex,
|
||||||
} from './streetLevelPresentation.js';
|
} from './streetLevelPresentation.js';
|
||||||
|
import { providerSnapshot } from '../testSupport/streetLevelFakes.mjs';
|
||||||
|
|
||||||
|
const provider = providerSnapshot;
|
||||||
|
|
||||||
|
test('an empty composition explains unavailable imagery even while disabled', () => {
|
||||||
|
for (const enabled of [false, true]) {
|
||||||
|
const view = presentStreetLevelPanel(
|
||||||
|
snapshot({
|
||||||
|
enabled,
|
||||||
|
providers: [],
|
||||||
|
coverage: { hint: 'No street-level imagery providers configured' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.equal(view.controlsDisabled, true);
|
||||||
|
assert.equal(view.keyRequired, false);
|
||||||
|
assert.deepEqual(view.providers, []);
|
||||||
|
assert.equal(view.meta, 'No street-level imagery providers configured');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function snapshot(overrides = {}) {
|
function snapshot(overrides = {}) {
|
||||||
const base = {
|
const base = {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
providerOn: true,
|
// The one provider's key state follows the summary the test asks for.
|
||||||
|
providers: [
|
||||||
|
provider({
|
||||||
|
keyRequired: overrides.keyRequired === true,
|
||||||
|
keyRejected: overrides.keyRejected === true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
keyRequired: false,
|
keyRequired: false,
|
||||||
keyRejected: false,
|
keyRejected: false,
|
||||||
filter: { pano: 'all', sinceDays: 0 },
|
filter: { pano: 'all', sinceDays: 0 },
|
||||||
@@ -17,6 +43,7 @@ function snapshot(overrides = {}) {
|
|||||||
open: false,
|
open: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
providerLabel: null,
|
||||||
imageId: null,
|
imageId: null,
|
||||||
position: null,
|
position: null,
|
||||||
bearing: null,
|
bearing: null,
|
||||||
@@ -204,7 +231,7 @@ test('the meta line never mixes the visible-sequence count with the selected seq
|
|||||||
test('an empty view says so, and whether the filter emptied it', () => {
|
test('an empty view says so, and whether the filter emptied it', () => {
|
||||||
const meta = (overrides) =>
|
const meta = (overrides) =>
|
||||||
presentStreetLevelPanel(snapshot({ enabled: true, ...overrides })).meta;
|
presentStreetLevelPanel(snapshot({ enabled: true, ...overrides })).meta;
|
||||||
assert.equal(meta({}), 'No Mapillary coverage in view');
|
assert.equal(meta({}), 'No coverage in view');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
meta({ filter: { pano: 'flat', sinceDays: 0 } }),
|
meta({ filter: { pano: 'flat', sinceDays: 0 } }),
|
||||||
'No sequences in view match the filter',
|
'No sequences in view match the filter',
|
||||||
@@ -225,6 +252,7 @@ test('viewer caption reads "Image by" left, date right, and links to Mapillary',
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
street: {
|
street: {
|
||||||
open: true,
|
open: true,
|
||||||
|
providerLabel: 'MAPILLARY',
|
||||||
imageId: '1814275685699406',
|
imageId: '1814275685699406',
|
||||||
creator: 'mapfool',
|
creator: 'mapfool',
|
||||||
capturedAt: Date.UTC(2023, 9, 8),
|
capturedAt: Date.UTC(2023, 9, 8),
|
||||||
|
|||||||
@@ -300,6 +300,43 @@
|
|||||||
color: rgba(148, 178, 188, 0.42);
|
color: rgba(148, 178, 188, 0.42);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.key-setup-cloud-auth {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-setup-cloud-auth-state {
|
||||||
|
color: rgba(190, 214, 222, 0.7);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.56rem;
|
||||||
|
letter-spacing: 0.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-setup-cloud-auth-toggle {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 0.28rem 0.55rem;
|
||||||
|
color: rgba(54, 220, 255, 0.9);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.55rem;
|
||||||
|
letter-spacing: 0.06rem;
|
||||||
|
background: rgba(54, 220, 255, 0.08);
|
||||||
|
border: 1px solid rgba(54, 220, 255, 0.28);
|
||||||
|
border-radius: 0.32rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-setup-cloud-auth-toggle:hover,
|
||||||
|
.key-setup-cloud-auth-toggle:focus-visible {
|
||||||
|
border-color: rgba(54, 220, 255, 0.65);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-setup-cloud-auth-toggle[aria-disabled='true'] {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
.key-setup-footer {
|
.key-setup-footer {
|
||||||
margin-top: 0.85rem;
|
margin-top: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,56 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Providers ───────────────────────────────────────────────────────────── */
|
||||||
|
.sl-providers {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sl-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* `--chip-color` comes from the provider. */
|
||||||
|
.sl-chips .data-toggle-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sl-chips .data-toggle-chip::before {
|
||||||
|
content: '';
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--chip-color, currentColor);
|
||||||
|
opacity: 0.45;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sl-chips .data-toggle-chip.active {
|
||||||
|
color: var(--chip-color, var(--accent));
|
||||||
|
border-color: var(--chip-color, var(--accent));
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--chip-color, var(--accent)) 14%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sl-chips .data-toggle-chip.active::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sl-chips .data-toggle-chip.chip-error {
|
||||||
|
color: #ffb300;
|
||||||
|
border-color: rgba(255, 179, 0, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Errors ──────────────────────────────────────────────────────────────── */
|
/* ── Errors ──────────────────────────────────────────────────────────────── */
|
||||||
.sl-error {
|
.sl-error {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
|
|||||||
@@ -107,8 +107,13 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sl-providers" role="group" aria-label="Imagery providers">
|
||||||
|
<span class="cctv-summary-label">PROVIDERS</span>
|
||||||
|
<div id="sl-provider-chips" class="sl-chips"></div>
|
||||||
|
</div>
|
||||||
<div id="sl-error" class="sl-error" role="alert" hidden><span aria-hidden="true">⚠</span> <span id="sl-error-text"></span></div>
|
<div id="sl-error" class="sl-error" role="alert" hidden><span aria-hidden="true">⚠</span> <span id="sl-error-text"></span></div>
|
||||||
<!-- A missing key gates the filters; the line above says how to add it. -->
|
<!-- A missing key gates the filters only: the chips above stay
|
||||||
|
focusable so their tooltip can say how to add the key. -->
|
||||||
<fieldset id="sl-controls" class="sl-controls">
|
<fieldset id="sl-controls" class="sl-controls">
|
||||||
<div class="sl-filters" role="group" aria-label="Imagery filters">
|
<div class="sl-filters" role="group" aria-label="Imagery filters">
|
||||||
<span class="cctv-summary-label">IMAGERY</span>
|
<span class="cctv-summary-label">IMAGERY</span>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
export const CLOUD_VOICE_AUTH_MODES = Object.freeze(['api-key', 'oauth']);
|
||||||
|
export const DEFAULT_CLOUD_VOICE_AUTH_MODE = 'api-key';
|
||||||
|
export const CLOUD_VOICE_AUTH_STORAGE_KEY = 'godsEyeView.voice.cloudAuth';
|
||||||
|
|
||||||
|
function storageRef(storage) {
|
||||||
|
if (storage) return storage;
|
||||||
|
try {
|
||||||
|
return typeof localStorage === 'undefined' ? null : localStorage;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCloudVoiceAuthMode(mode) {
|
||||||
|
const normalized = String(mode || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
return CLOUD_VOICE_AUTH_MODES.includes(normalized)
|
||||||
|
? normalized
|
||||||
|
: DEFAULT_CLOUD_VOICE_AUTH_MODE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readStoredCloudVoiceAuthMode(storage) {
|
||||||
|
try {
|
||||||
|
return normalizeCloudVoiceAuthMode(
|
||||||
|
storageRef(storage)?.getItem(CLOUD_VOICE_AUTH_STORAGE_KEY),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_CLOUD_VOICE_AUTH_MODE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeStoredCloudVoiceAuthMode(mode, storage) {
|
||||||
|
const resolved = normalizeCloudVoiceAuthMode(mode);
|
||||||
|
try {
|
||||||
|
storageRef(storage)?.setItem(CLOUD_VOICE_AUTH_STORAGE_KEY, resolved);
|
||||||
|
} catch {
|
||||||
|
/* best effort */
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
CLOUD_VOICE_AUTH_STORAGE_KEY,
|
||||||
|
readStoredCloudVoiceAuthMode,
|
||||||
|
writeStoredCloudVoiceAuthMode,
|
||||||
|
} from './cloudVoiceAuth.js';
|
||||||
|
|
||||||
|
function memoryStorage(seed = {}) {
|
||||||
|
const values = new Map(Object.entries(seed));
|
||||||
|
return {
|
||||||
|
getItem: (key) => values.get(key) ?? null,
|
||||||
|
setItem: (key, value) => values.set(key, value),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('cloud voice auth defaults to API key and persists OAuth independently', () => {
|
||||||
|
const storage = memoryStorage();
|
||||||
|
assert.equal(readStoredCloudVoiceAuthMode(storage), 'api-key');
|
||||||
|
assert.equal(writeStoredCloudVoiceAuthMode('oauth', storage), 'oauth');
|
||||||
|
assert.equal(readStoredCloudVoiceAuthMode(storage), 'oauth');
|
||||||
|
assert.equal(writeStoredCloudVoiceAuthMode('unknown', storage), 'api-key');
|
||||||
|
assert.equal(
|
||||||
|
readStoredCloudVoiceAuthMode(
|
||||||
|
memoryStorage({ [CLOUD_VOICE_AUTH_STORAGE_KEY]: 'oauth' }),
|
||||||
|
),
|
||||||
|
'oauth',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -3195,6 +3195,54 @@ test('F1: the toggle still records the next-session preference while live', () =
|
|||||||
assert.match(ui.tierButton.title, /this session stays on/i);
|
assert.match(ui.tierButton.title, /this session stays on/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('OAuth cloud sessions report unknown cost and usage without tripping the API spend guard', () => {
|
||||||
|
const { controller, ui } = costControllerHarness();
|
||||||
|
controller.status = 'listening';
|
||||||
|
controller._cost.cloudVoiceAuth = 'oauth';
|
||||||
|
controller._cost.sessionCloudVoiceAuth = 'oauth';
|
||||||
|
controller.syncCostUi();
|
||||||
|
const before = controller.costTracker.state();
|
||||||
|
assert.equal(ui.costValue.hidden, false);
|
||||||
|
assert.equal(ui.costValue.textContent, 'COST UNKNOWN');
|
||||||
|
assert.deepEqual(controller.recordUsage(usdUsage(100)), {
|
||||||
|
responses: 1,
|
||||||
|
input: 0,
|
||||||
|
output: 1562500,
|
||||||
|
captions: 0,
|
||||||
|
costKnown: false,
|
||||||
|
});
|
||||||
|
const after = controller.costTracker.state();
|
||||||
|
assert.equal(after.totalUsd, before.totalUsd);
|
||||||
|
assert.equal(controller.costCapStopped, false);
|
||||||
|
assert.match(ui.costValue.title, /1 response\(s\).*1562500 output tokens/);
|
||||||
|
controller.dc = {};
|
||||||
|
controller.responseActive = true;
|
||||||
|
controller.stop();
|
||||||
|
assert.match(ui.costValue.title, /Usage is incomplete/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OAuth cloud sessions count caption transcriptions without pricing them into the API cap', () => {
|
||||||
|
const { controller, ui } = costControllerHarness();
|
||||||
|
controller.status = 'listening';
|
||||||
|
controller._cost.cloudVoiceAuth = 'oauth';
|
||||||
|
controller._cost.sessionCloudVoiceAuth = 'oauth';
|
||||||
|
controller._cost.bindTranscriptionModel('gpt-4o-mini-transcribe');
|
||||||
|
const before = controller.costTracker.state();
|
||||||
|
// Priced, this much caption audio would cross the $5 cap.
|
||||||
|
const result = controller.recordTranscriptionUsage({
|
||||||
|
type: 'duration',
|
||||||
|
seconds: 3_600_000,
|
||||||
|
});
|
||||||
|
assert.equal(result.captions, 1);
|
||||||
|
assert.equal(result.costKnown, false);
|
||||||
|
const after = controller.costTracker.state();
|
||||||
|
assert.equal(after.totalUsd, before.totalUsd);
|
||||||
|
assert.equal(after.capReached, false);
|
||||||
|
assert.equal(controller.costCapStopped, false);
|
||||||
|
assert.equal(ui.costValue.textContent, 'COST UNKNOWN');
|
||||||
|
assert.match(ui.costValue.title, /1 caption transcription\(s\)/);
|
||||||
|
});
|
||||||
|
|
||||||
test('F1: when idle, toggling does re-price the preview meter', () => {
|
test('F1: when idle, toggling does re-price the preview meter', () => {
|
||||||
const { controller } = costControllerHarness();
|
const { controller } = costControllerHarness();
|
||||||
controller.status = 'idle';
|
controller.status = 'idle';
|
||||||
|
|||||||
@@ -15,11 +15,19 @@ export function createRealtimeBackend({
|
|||||||
);
|
);
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
protocol: 'openai-realtime',
|
protocol: 'openai-realtime',
|
||||||
async requestToken({ tier = DEFAULT_VOICE_TIER, signal } = {}) {
|
async requestToken({
|
||||||
|
tier = DEFAULT_VOICE_TIER,
|
||||||
|
authMode = 'api-key',
|
||||||
|
signal,
|
||||||
|
} = {}) {
|
||||||
signal = scoped(signal);
|
signal = scoped(signal);
|
||||||
signal.throwIfAborted();
|
signal.throwIfAborted();
|
||||||
const separator = tokenEndpoint.includes('?') ? '&' : '?';
|
const separator = tokenEndpoint.includes('?') ? '&' : '?';
|
||||||
const url = `${tokenEndpoint}${separator}tier=${encodeURIComponent(resolveVoiceModel(tier).tier)}`;
|
const query = new URLSearchParams({
|
||||||
|
tier: resolveVoiceModel(tier).tier,
|
||||||
|
});
|
||||||
|
if (authMode === 'oauth') query.set('auth', 'oauth');
|
||||||
|
const url = `${tokenEndpoint}${separator}${query}`;
|
||||||
const response = await tokenTransport(url, {
|
const response = await tokenTransport(url, {
|
||||||
signal,
|
signal,
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
|
|||||||
@@ -41,6 +41,22 @@ test('separate configured token and SDP transports preserve model metadata and c
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('OAuth is an additive token query while API key stays the default', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const backend = createRealtimeBackend({
|
||||||
|
tokenTransport: async (input) => {
|
||||||
|
calls.push(input);
|
||||||
|
return tokenReply();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await backend.requestToken();
|
||||||
|
await backend.requestToken({ authMode: 'oauth' });
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'/api/realtime/token?tier=standard',
|
||||||
|
'/api/realtime/token?tier=standard&auth=oauth',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('denied tokens never negotiate or retry; reconnect requests a fresh token', async () => {
|
test('denied tokens never negotiate or retry; reconnect requests a fresh token', async () => {
|
||||||
let requests = 0;
|
let requests = 0;
|
||||||
const backend = createRealtimeBackend({
|
const backend = createRealtimeBackend({
|
||||||
|
|||||||
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user