diff --git a/.env.example b/.env.example index 55cb367a0..0876262b2 100644 --- a/.env.example +++ b/.env.example @@ -115,7 +115,8 @@ GEMINI_LIVE_MODEL=gemini-3.8-live # Client Token). Client-exposed by design, like GOOGLE_MAPS_API_KEY. MAPILLARY_CLIENT_TOKEN= -# Optional: OpenSky auth mode (oauth | basic | auto | anon) +# Optional: OpenSky auth mode (oauth | auto | anon). OpenSky retired +# username/password (basic) sign-in on 2026-03-18; "basic" now loads anonymously. # Default: oauth — requires client credentials below # Set to "anon" to skip aircraft auth (rate-limited but works) OPENSKY_AUTH_MODE=oauth @@ -128,7 +129,9 @@ OPENSKY_CLIENT_SECRET= # Optional paid Patreon tokens raise request limits; there is no free API key. LL2_API_TOKEN= -# Optional: OpenSky credentials JSON file path (alternative to above) +# Optional: OpenSky credentials JSON file path, used when the two values above +# are empty: the credentials.json your OpenSky account page downloads when you +# create an API client. Use an absolute path for compatibility with launchers. # OPENSKY_CREDENTIALS_FILE=/path/to/credentials.json # Optional: dev server config diff --git a/changelog.d/1229-opensky-oauth-only.md b/changelog.d/1229-opensky-oauth-only.md new file mode 100644 index 000000000..25159830b --- /dev/null +++ b/changelog.d/1229-opensky-oauth-only.md @@ -0,0 +1,8 @@ +- OpenSky sign-in matches what OpenSky accepts since 2026-03-18. The server + now reads `OPENSKY_CREDENTIALS_FILE` (the `credentials.json` OpenSky's + account page downloads) on every platform, not only through the macOS + launcher, and no longer sends retired username/password Basic auth: + `OPENSKY_AUTH_MODE=basic` loads anonymously with one warning saying what to + set instead (quanticsoul4772, #1229). +- Credential-file warnings omit parser excerpts and filesystem error details; + the path example also works with the macOS launchers. diff --git a/changelog.d/1282-polling-issue-fix.md b/changelog.d/1282-polling-issue-fix.md new file mode 100644 index 000000000..98885fc20 --- /dev/null +++ b/changelog.d/1282-polling-issue-fix.md @@ -0,0 +1,7 @@ +- Pause periodic network updates in hidden tabs; the overdue update on return restarts the polling schedule, so the next poll follows one interval later even when that update fails (asturksever, #1282). +- Report and guard browser visibility during Transit QA so hidden-page polling pauses cannot masquerade as playback regressions (asturksever, #1282). +- Require each live city to supply its own feed vehicles in Transit QA; retained vehicles cannot qualify another city (asturksever, #1282). +- Bound the Transit card-anchor probe by observed poll coverage instead of a fixed 20-second window (asturksever, #1282). +- Keep visible-page Transit probes focused through the browser testing API (asturksever, #1282). +- Add a real-browser regression gate for hidden-tab periodic work, explicit refresh, resume and teardown (asturksever, #1282). +- Restore minimized test windows before screenshots so native compositor pauses cannot stall browser acceptance (asturksever, #1282). diff --git a/changelog.d/1304-antimeridian-distances.md b/changelog.d/1304-antimeridian-distances.md new file mode 100644 index 000000000..fc7744f86 --- /dev/null +++ b/changelog.d/1304-antimeridian-distances.md @@ -0,0 +1,2 @@ +- Point distances wrap longitude differences across the antimeridian, in voice + proximity checks and `places_nearby` (quanticsoul4772, #1304). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 3db6d3fb9..0acd7575a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -9,13 +9,7 @@ broader suffix variants. Follow-ups retain boundary provenance, and cancelled lookups preserve the previous analyst cohort and boundary source. Navigation search keeps a successful geocode, with no search or view-based region recovery. -Overpass reserves each normalized query before its disk-cache lookup. The -initial disk lookup, upstream quota admission, fetching and memory publication -are coalesced under that reservation. A caller that joined a reservation -rejected by another client's quota retries admission with its own quota. -Stale fallback runs per response after the reservation is released, and disk -writes finish asynchronously. Settled and rejected reservations are removed -only while they still own their map entry. +Manager-owned periodic network updates pause while the document is hidden. Returning to a visible tab runs one overdue update per enabled layer without overlapping an active refresh and restarts that layer's polling schedule from the return, so the next poll follows one interval later whether or not the catch-up update succeeds; explicit refresh requests remain available. Transit also defers camera-driven feed admission while hidden and rechecks coverage on return. Visibility listeners are released with their polling intervals. Terrain height requests accept at most 64 points, matching the browser service's existing batch size and one bounded upstream chunk. Larger requests return HTTP @@ -1918,6 +1912,10 @@ Updated: September 23, 2026 ## Aircraft and vessel server modules +OpenSky credential-file failures emit a fixed warning once per process; neither +JSON parser excerpts nor filesystem error messages are logged. The environment +example uses an absolute path compatible with the macOS launchers. + Standalone aircraft routes now live in `server/providers/aircraft/`: OpenSky state vectors and regional fallback, adsb.lol military positions, ADSBDB enrichment and track backfill. Vessel routes and websocket/watchdog setup live @@ -4219,6 +4217,8 @@ animation. Polls, surface completions and trail fades request individual renders Disable clears vehicles, detection references, requests, timers and holds; destruction also releases collections and registrations. +The `scripts/qa-layer-visibility.mjs` gate uses the development-only registration seam and real browser tab visibility to prove periodic work pauses while hidden, explicit refresh still works, resume performs one overdue refresh, and disabled layers stay stopped. + **Acceptance harness.** Run against an already running development server: ```sh @@ -5517,6 +5517,11 @@ easier to meet (detection is now on more often), but does not create it. short-way-round longitude span and wraps the padded edges, so a dateline- crossing box stays its true width. Raw subtraction inflated a 0.41° metro box to 86.7° and a 60° territory to 132°. +- **Point distances take the short way round 180°.** The annotation + resolver's `approximateDistanceM` (voice "point out" proximity checks) and + `places_nearby` distances wrap the longitude difference. Raw subtraction put + Savusavu 38,148 km from a camera over the Lau Islands, so the proximity check + refused it, and sorted a place 2 km across 180° last. - CCTV calibration persists at `godsEyeView.cctv.calibration.v2` (v2 rebuild; the store was wiped clean, no import from the old `v1` key). - CCTV v3 floor QA pins are: zero samples for heading-only edits; zero transient diff --git a/docs/opensky-auth.md b/docs/opensky-auth.md index 67844f912..c5f0a387b 100644 --- a/docs/opensky-auth.md +++ b/docs/opensky-auth.md @@ -3,10 +3,11 @@ God's Eye View uses explicit auth modes for `/api/flights`: - `OPENSKY_AUTH_MODE=oauth` (default, recommended) -- `OPENSKY_AUTH_MODE=auto` (OAuth first, then Basic fallback) -- `OPENSKY_AUTH_MODE=basic` +- `OPENSKY_AUTH_MODE=auto` (OAuth when configured, otherwise anonymous) - `OPENSKY_AUTH_MODE=anon` +`OPENSKY_AUTH_MODE=basic` is retired (see below). + Reference: OpenSky REST API docs recommend OAuth2 Client Credentials flow. ## Quick Start (OAuth Client JSON) @@ -41,13 +42,18 @@ Launchers resolve OAuth creds in this order: 2. `OPENSKY_CREDENTIALS_FILE` 3. Keychain (`opensky-network` / `client_id`, `client_secret`) -## Basic Auth (Legacy) +The server itself reads 1 and 2, so `npm run dev`, `npm run preview` and +Pinokio also work with only `OPENSKY_CREDENTIALS_FILE` set in `.env`, on any +platform. It reads the file on each token refresh. -Basic auth is still supported for now, but OpenSky is moving away from it. +## Basic Auth (Retired) -```bash -OPENSKY_AUTH_MODE=basic ./scripts/dev-fresh.sh -``` +OpenSky stopped accepting username/password (HTTP Basic) sign-in on +2026-03-18; its API now takes only OAuth2 client credentials. With +`OPENSKY_AUTH_MODE=basic` the server no longer sends `OPENSKY_USERNAME` / +`OPENSKY_PASSWORD`: it requests anonymously (reason `basic_retired`) and +logs once what to set instead. Create an API client on your OpenSky +account page and use its `credentials.json` as above. ## Troubleshooting @@ -61,6 +67,6 @@ Useful reasons: - `oauth_invalid_or_missing`: OAuth client not found/usable - `oauth_invalid_credentials`: OAuth client rejected by OpenSky -- `basic_invalid_credentials`: username/password rejected -- `missing_basic_creds`: basic mode missing user/pass +- `missing_oauth_creds`: auto mode found no OAuth client +- `basic_retired`: basic mode requested; answered anonymously - `rate_limited`: OpenSky throttling diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 7b7171c68..f88be2b6b 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -6,6 +6,7 @@ "scripts/qa-form-fields.mjs", "scripts/package-boundaries.json", "scripts/format.mjs", + "scripts/qa-layer-visibility.mjs", "scripts/next-layer-state-token.mjs", "scripts/check-layer-state-tokens.mjs", "scripts/qa-layer-token-twochar.mjs", diff --git a/scripts/qa-layer-visibility.mjs b/scripts/qa-layer-visibility.mjs new file mode 100644 index 000000000..8a903918e --- /dev/null +++ b/scripts/qa-layer-visibility.mjs @@ -0,0 +1,124 @@ +#!/usr/bin/env node +/** + * Real-browser proof for manager-owned periodic work and document visibility. + * Uses the app's development-only layer registration seam; no provider keys. + * Run: node scripts/qa-layer-visibility.mjs --url http://localhost:4173 + * Hiding uses a real background tab, never a patched document.hidden getter. + */ +import assert from 'node:assert/strict'; +import puppeteer from 'puppeteer'; +import { chromeExecutablePath } from './qa-chrome.mjs'; + +const args = process.argv.slice(2); +const urlIndex = args.indexOf('--url'); +const url = new URL( + (urlIndex >= 0 ? args[urlIndex + 1] : undefined) || + process.env.QA_BASE_URL || + 'http://localhost:4173', +); +url.searchParams.set('welcome', '0'); +const browser = await puppeteer.launch({ + executablePath: chromeExecutablePath(), + headless: true, + args: [ + '--no-sandbox', + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + ], +}); +const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const check = (name, actual, expected) => { + assert.equal(actual, expected, name); + console.log(`PASS ${name}: ${actual}`); +}; +try { + const page = await browser.newPage(); + const focus = await page.createCDPSession(); + await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true }); + await page.goto(url.href, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction( + () => window.__gevQaRegisterLayer && window.__godsEyeView?.dataManager, + ); + await page.evaluate(async () => { + const manager = window.__godsEyeView.dataManager; + window.__qaVisibilityUpdates = 0; + window.__gevQaRegisterLayer(manager, { + id: 'qa-document-visibility', + name: 'Visibility QA', + updateInterval: 750, + init() {}, + enable() {}, + disable() {}, + destroy() {}, + async update() { + window.__qaVisibilityUpdates++; + return true; + }, + getStats() { + return { count: window.__qaVisibilityUpdates }; + }, + }); + await manager.setEnabled('qa-document-visibility', true, { origin: 'qa' }); + }); + const count = () => page.evaluate(() => window.__qaVisibilityUpdates); + await page.waitForFunction(() => window.__qaVisibilityUpdates >= 3); + check('periodic updates run while visible', (await count()) >= 3, true); + await focus.send('Emulation.setFocusEmulationEnabled', { enabled: false }); + const cover = await browser.newPage(); + await cover.bringToFront(); + await page.waitForFunction(() => document.visibilityState === 'hidden'); + const hiddenCount = await count(); + await pause(2200); + check('hidden tab makes no periodic updates', await count(), hiddenCount); + check( + 'explicit refresh remains available while hidden', + await page.evaluate(() => + window.__godsEyeView.dataManager.refreshLayer('qa-document-visibility'), + ), + true, + ); + check('explicit hidden refresh runs once', await count(), hiddenCount + 1); + await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true }); + await page.waitForFunction( + () => + document.visibilityState === 'visible' && + window.__qaVisibilityUpdates > 0, + ); + await pause(200); + check( + 'resume performs one overdue refresh without a catch-up burst', + await count(), + hiddenCount + 2, + ); + await page.evaluate(() => + window.__godsEyeView.dataManager.setEnabled( + 'qa-document-visibility', + false, + { origin: 'qa' }, + ), + ); + const stopped = await count(); + await focus.send('Emulation.setFocusEmulationEnabled', { enabled: false }); + await cover.bringToFront(); + await page.waitForFunction(() => document.visibilityState === 'hidden'); + await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true }); + await pause(1100); + check( + 'disabled layer does not resume on visibility change', + await count(), + stopped, + ); + check( + 'QA layer destroys cleanly', + await page.evaluate(() => + window.__gevQaUnregisterLayer( + window.__godsEyeView.dataManager, + 'qa-document-visibility', + ), + ), + true, + ); + console.log('RESULT: 7 passed'); +} finally { + await browser.close(); +} diff --git a/scripts/qa-transit-browser.mjs b/scripts/qa-transit-browser.mjs index eb7b59985..23925c743 100644 --- a/scripts/qa-transit-browser.mjs +++ b/scripts/qa-transit-browser.mjs @@ -47,6 +47,7 @@ export async function reportTransitVisibility(page, label) { const layer = app.dataManager.layers.get('transit').module; const state = layer._transitStateForTest(); return { + pageVisibility: document.visibilityState, total: state._vehicles.size, visible: state._visible.size, moving: state._moving.size, @@ -59,6 +60,10 @@ export async function reportTransitVisibility(page, label) { }; }); console.log(` ${label}: ${JSON.stringify(result)}`); + if (result.pageVisibility !== 'visible') + throw new Error( + `${label}: Transit QA requires a visible page; hidden pages deliberately pause polling`, + ); if (result.renderLoop === false || result.renderErrors.length) throw new Error( `${label}: Cesium render loop stopped or raised an error: ${JSON.stringify(result.renderErrors)}`, diff --git a/scripts/qa-transit-controls.mjs b/scripts/qa-transit-controls.mjs index b76e14706..70ed1503c 100644 --- a/scripts/qa-transit-controls.mjs +++ b/scripts/qa-transit-controls.mjs @@ -1,6 +1,7 @@ const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); export const BOSTON = { id: 'boston', + feedId: 'mbta', label: 'Boston (MBTA)', lat: 42.3554, lon: -71.0605, @@ -11,6 +12,7 @@ const LIVE_CITIES = [ BOSTON, { id: 'austin', + feedId: 'capmetro-austin', label: 'Austin (CapMetro)', lat: 30.2672, lon: -97.7431, @@ -19,6 +21,7 @@ const LIVE_CITIES = [ }, { id: 'minneapolis', + feedId: 'metrotransit-msp', label: 'Minneapolis (Metro Transit)', lat: 44.9778, lon: -93.265, @@ -27,6 +30,7 @@ const LIVE_CITIES = [ }, { id: 'helsinki', + feedId: 'hsl-helsinki', label: 'Helsinki (HSL)', lat: 60.1699, lon: 24.9384, @@ -35,6 +39,7 @@ const LIVE_CITIES = [ }, { id: 'oslo', + feedId: 'entur-norway', label: 'Oslo (Entur)', lat: 59.9139, lon: 10.7522, diff --git a/scripts/qa-transit-recovery.mjs b/scripts/qa-transit-recovery.mjs index 5f6404b41..a54e81239 100644 --- a/scripts/qa-transit-recovery.mjs +++ b/scripts/qa-transit-recovery.mjs @@ -1,6 +1,6 @@ /** Camera-jump regression against an existing server; never starts a server. */ import puppeteer from 'puppeteer'; -import { chromeExecutablePath } from './qa-chrome.mjs'; +import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs'; import { mkdir, writeFile } from 'node:fs/promises'; import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js'; @@ -307,7 +307,7 @@ try { result.pixelPass = pixels.pass; result.pixelDetails = pixels; result.errors = errors; - await page.screenshot({ path: `${shots}/recovery.png` }); + await captureQaScreenshot(page, { path: `${shots}/recovery.png` }); await writeFile(`${shots}/recovery.json`, JSON.stringify(result, null, 2)); for (const key of ['on', 'off', 'onAgain', 'offAgain']) delete result[key]; const pass = diff --git a/scripts/qa-transit-scenes.mjs b/scripts/qa-transit-scenes.mjs index 1ceb45b22..f325cb9f7 100644 --- a/scripts/qa-transit-scenes.mjs +++ b/scripts/qa-transit-scenes.mjs @@ -1,3 +1,4 @@ +import { captureQaScreenshot } from './qa-chrome.mjs'; import { getRegisteredTransitFeed } from '../src/data/transitFeeds.js'; // Not registered or active: camera discovery and live polls never own this feed. @@ -219,7 +220,7 @@ export async function runFleetBudgets({ allocations.layerFrameBytes / allocationTrace.rows.length <= 8192, `${allocations.layerFrameBytes / allocationTrace.rows.length} transit-authored bytes/frame; whole frame ${allocations.frameBytes / allocationTrace.rows.length}; top three ${JSON.stringify(details.topAllocatorsPerFrame)}; collected objects included. Full profile saved.`, ); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-fleet-${count}.jpg`, type: 'jpeg', quality: 85, @@ -392,7 +393,7 @@ export async function runBostonMatrix({ JSON.stringify({ pixels, contrast }), { unexercised: pixels.length < 6 }, ); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-matrix-${background.name}-${look.name}-fleet.jpg`, type: 'jpeg', quality: 90, @@ -407,7 +408,7 @@ export async function runBostonMatrix({ target.key, ); await wait(500); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-matrix-${background.name}-${look.name}-${target.route}-selected.jpg`, type: 'jpeg', quality: 90, @@ -431,7 +432,7 @@ export async function runBostonMatrix({ ); } await wait(300); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-matrix-${background.name}-${look.name}-restored.jpg`, type: 'jpeg', quality: 90, @@ -579,7 +580,7 @@ export async function runBostonLive({ JSON.stringify(selection), { unexercised: !target }, ); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-boston-live-${view.name}.jpg`, type: 'jpeg', quality: 90, @@ -1010,7 +1011,7 @@ export async function runTrailVisibility({ stable: pixels.stable[i], eligible: pixels.eligible[i], })); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-${altitude}m-trail-visible.jpg`, type: 'jpeg', quality: 95, diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs index fb0fd0bd9..49a25fd18 100644 --- a/scripts/qa-transit.mjs +++ b/scripts/qa-transit.mjs @@ -36,10 +36,11 @@ import { */ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import puppeteer from 'puppeteer'; -import { chromeExecutablePath } from './qa-chrome.mjs'; +import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs'; import { createTransitHistory } from '../server/providers/transitHistory.js'; import { DETECTION_THEME_MAP } from '../src/overlays/worldOverlayTokens.js'; import { transitModeTier } from '../src/data/transitPresetStyle.js'; +import { TRANSIT_POLL_MS } from '../src/layers/transit/policy.js'; import { reduceAnchor, @@ -264,6 +265,7 @@ const browser = await puppeteer.launch({ executablePath: chromeExecutablePath(), args: [ '--no-sandbox', + '--disable-backgrounding-occluded-windows', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1920,1080', @@ -271,6 +273,10 @@ const browser = await puppeteer.launch({ ], }); const page = await browser.newPage(); +// This gate measures visible-page motion. OS minimization must not silently +// switch it into the app's intentional hidden-tab polling pause. +const focus = await page.createCDPSession(); +await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true }); boundPageEvaluations(page); await page.setViewport({ width: 1920, height: 1080 }); const pageErrors = []; @@ -286,11 +292,13 @@ page.on('request', (request) => { }); const shot = (name, caption) => - page - .screenshot({ path: `${SHOTS}/${name}`, type: 'jpeg', quality: 82 }) - .then(() => { - console.log(` shot ${name} — ${caption}`); - }); + captureQaScreenshot(page, { + path: `${SHOTS}/${name}`, + type: 'jpeg', + quality: 82, + }).then(() => { + console.log(` shot ${name} — ${caption}`); + }); const view = (city) => `${BASE}/?welcome=0#lat=${city.lat}&lon=${city.lon}&alt=600&heading=${city.heading}&pitch=-45`; @@ -389,22 +397,42 @@ try { orientation: { heading: 0, pitch: (-40 * Math.PI) / 180, roll: 0 }, }); }, city); - await page.waitForFunction( - () => - (window.__godsEyeView.dataManager.layers - .get('transit') - .module.getStats().count || 0) > 0, - { timeout: 90_000 }, - ); + const arrived = await page + .waitForFunction( + (feedId) => + [ + ...window.__godsEyeView.dataManager.layers + .get('transit') + .module._transitStateForTest() + ._vehicles.keys(), + ].some((key) => key.startsWith(`${feedId}:`)), + { timeout: 90_000 }, + city.feedId, + ) + .then( + () => true, + () => false, + ); + if (!arrived) { + check( + `${city.label}: live feed arrived`, + false, + `No vehicles from ${city.feedId}; retained vehicles from other cities cannot qualify this view`, + { unexercised: true }, + ); + continue; + } await wait(9_000); - const fleet = await page.evaluate(() => { + const fleet = await page.evaluate((feedId) => { const layer = window.__godsEyeView.dataManager.layers.get('transit').module; return { - count: layer._transitStateForTest()._vehicles.size, + count: [...layer._transitStateForTest()._vehicles.keys()].filter( + (key) => key.startsWith(`${feedId}:`), + ).length, stats: layer.getStats(), }; - }); + }, city.feedId); check( `${city.label}: vehicles are rendered`, fleet.count >= 20, @@ -426,6 +454,7 @@ try { .module._transitStateForTest(); let best = null; for (const entry of state._vehicles.values()) { + if (!entry.key.startsWith(`${c.feedId}:`)) continue; const d = Math.hypot(entry.to.lat - c.lat, entry.to.lon - c.lon); if (!best || d < best.d) best = { d, lat: entry.to.lat, lon: entry.to.lon, key: entry.key }; @@ -1607,6 +1636,7 @@ try { m[3] * v[0] + m[7] * v[1] + m[11] * v[2] + m[15] * v[3], ]; const frames = []; + const observedPolls = new Set(); const publicationsBefore = state._cardPublications || 0; let headSamples = 0, headErrorPx = 0, @@ -1623,6 +1653,13 @@ try { const proj = scene.camera.frustum.projectionMatrix; const p = entry.marker.position; const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1])); + if ( + entry.marker.show !== false && + rect && + Number.isFinite(entry.pollSeq) + ) { + observedPolls.add(entry.pollSeq); + } frames.push({ t: performance.now() - t0, mx: ((clip[0] / clip[3]) * 0.5 + 0.5) * canvas.clientWidth, @@ -1668,7 +1705,15 @@ try { headSamples++; } } - if (performance.now() - t0 >= durationMs) finish(); + const elapsed = performance.now() - t0; + // Keep at least 20 seconds of motion and require an observed poll + // transition. A fixed 20s wall-clock window can miss a 15s poll + // when an earlier refresh is still settling. + if ( + (elapsed >= 20_000 && observedPolls.size >= 2) || + elapsed >= durationMs + ) + finish(); }; let remove; const finish = () => { @@ -1676,6 +1721,7 @@ try { remove?.(); resolve({ selected: true, + elapsedMs: performance.now() - t0, frames, textPublishes: (state._cardPublications || 0) - publicationsBefore, @@ -1690,7 +1736,7 @@ try { }); }, target.key, - 20_000, + 3 * TRANSIT_POLL_MS, ); } check( @@ -1711,8 +1757,9 @@ try { ); check( 'card text publication stays throttled while the anchor runs at frame rate', - anchor.textPublishes > 0 && anchor.textPublishes <= 20_000 / 250 + 3, - `${anchor.textPublishes} text publications in 20 s`, + anchor.textPublishes > 0 && + anchor.textPublishes <= anchor.elapsedMs / 250 + 3, + `${anchor.textPublishes} text publications in ${(anchor.elapsedMs / 1000).toFixed(1)} s`, ); } else { check( diff --git a/scripts/setup-doctor.mjs b/scripts/setup-doctor.mjs index 01e048091..7075be2c1 100644 --- a/scripts/setup-doctor.mjs +++ b/scripts/setup-doctor.mjs @@ -124,6 +124,19 @@ export function resolveOpenSkyAuthMode({ return OPENSKY_AUTH_MODES.has(mode) ? mode : OPENSKY_AUTH_MODE_DEFAULT; } +/** Whether OPENSKY_CREDENTIALS_FILE is set, with the same precedence as the auth mode. The server reads that file when the client id and secret are empty. */ +export function resolveOpenSkyCredentialsFile({ + authoritativeEnvironment = false, + environment = process.env, + rootDir = ROOT, +} = {}) { + const environmentDefines = Object.prototype.hasOwnProperty.call(environment, 'OPENSKY_CREDENTIALS_FILE'); + const value = authoritativeEnvironment && environmentDefines + ? String(environment.OPENSKY_CREDENTIALS_FILE || '') + : String(environment.OPENSKY_CREDENTIALS_FILE || '') || readDoctorDotenvValue('OPENSKY_CREDENTIALS_FILE', rootDir); + return isConfiguredValue(value.trim()); +} + function hasKeychainItem(service, account) { if (process.platform !== 'darwin') return false; const result = spawnSync('security', [ @@ -153,10 +166,11 @@ export function resolveCredential(spec, { export function buildCapabilitySummary( credentials, - { openSkyAuthMode = OPENSKY_AUTH_MODE_DEFAULT } = {}, + { openSkyAuthMode = OPENSKY_AUTH_MODE_DEFAULT, openSkyCredentialsFile = false } = {}, ) { const configured = (name) => credentials[name]?.configured === true; - const hasOAuthCredentials = configured('OPENSKY_CLIENT_ID') && configured('OPENSKY_CLIENT_SECRET'); + const hasClientPair = configured('OPENSKY_CLIENT_ID') && configured('OPENSKY_CLIENT_SECRET'); + const hasOAuthCredentials = hasClientPair || openSkyCredentialsFile === true; const requestedOpenSkyMode = String(openSkyAuthMode || '').trim().toLowerCase(); const openSkyMode = OPENSKY_AUTH_MODES.has(requestedOpenSkyMode) ? requestedOpenSkyMode @@ -164,11 +178,13 @@ export function buildCapabilitySummary( const flights = openSkyMode === 'anon' ? 'OpenSky keyless anonymous access (rate-limited)' : openSkyMode === 'basic' - ? 'OpenSky Basic mode selected (credential presence and validity not verified)' + ? 'OpenSky Basic mode selected: OpenSky retired username/password sign-in, so flights load anonymously (rate-limited)' : openSkyMode === 'auto' ? 'OpenSky auto mode selected (runtime credential choice and validity not verified)' - : hasOAuthCredentials + : hasClientPair ? 'OpenSky OAuth credentials present (runtime mode and validity not verified)' + : hasOAuthCredentials + ? 'OpenSky OAuth credentials file set (OPENSKY_CREDENTIALS_FILE; file contents and validity not verified)' : 'OpenSky keyless anonymous access (rate-limited)'; const route = selectMapStartupRoute({ googleApiKey: configured('GOOGLE_MAPS_API_KEY') ? 'configured' : '', @@ -215,6 +231,7 @@ export function inspectSetup({ includeKeychain = true, authoritativeEnvironment credentials, capabilities: buildCapabilitySummary(credentials, { openSkyAuthMode: resolveOpenSkyAuthMode({ authoritativeEnvironment, rootDir }), + openSkyCredentialsFile: resolveOpenSkyCredentialsFile({ authoritativeEnvironment, rootDir }), }), }; } diff --git a/server/providers/aircraft/opensky.js b/server/providers/aircraft/opensky.js index a69885e2f..f7019d752 100644 --- a/server/providers/aircraft/opensky.js +++ b/server/providers/aircraft/opensky.js @@ -1,3 +1,5 @@ +import fs from 'node:fs'; +import os from 'node:os'; import { normalizeAdsbLolPointResponse } from '../../../src/data/adsbLolFallback.js'; import { coalesceProxyRequest, @@ -137,6 +139,10 @@ function openskyAdaptiveTtlMs(remaining) { } /** @type {boolean} Guards duplicate auth-failure warnings in logs. */ let _openskyAuthWarned = false; +/** One warning per process when OPENSKY_CREDENTIALS_FILE cannot be read. */ +let _openskyCredentialsFileWarned = false; +/** One warning per process when OPENSKY_AUTH_MODE=basic is requested. */ +let _openskyBasicRetiredWarned = false; /** @type {boolean} Guards duplicate invalid-auth-mode warnings. */ let _openskyAuthModeWarned = false; /** Default auth mode when OPENSKY_AUTH_MODE env is unset. */ @@ -156,6 +162,48 @@ const ADSBLOL_POINT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; // current instead of coasting a stale worldwide frame indefinitely. const OPENSKY_SOURCE_STALE_MS = 120_000; +/** + * The OpenSky API client for OAuth: OPENSKY_CLIENT_ID and + * OPENSKY_CLIENT_SECRET, or else the credentials.json that OpenSky's account + * page downloads, named by OPENSKY_CREDENTIALS_FILE ("~" is the home + * directory). The file is read on each token refresh, so a replaced file is + * picked up without a restart. + * + * @param {Record} [env] + * @param {(path: string) => string} [readFile] + * @returns {{ clientId: string, clientSecret: string } | null} + */ +export function openSkyClientCredentials( + env = process.env, + readFile = (path) => fs.readFileSync(path, 'utf8'), +) { + const clientId = String(env.OPENSKY_CLIENT_ID || '').trim(); + const clientSecret = String(env.OPENSKY_CLIENT_SECRET || '').trim(); + if (clientId && clientSecret) return { clientId, clientSecret }; + const file = String(env.OPENSKY_CREDENTIALS_FILE || '').trim(); + if (!file) return null; + const path = file.replace(/^~(?=$|[\\/])/, os.homedir()); + let raw; + try { + raw = JSON.parse(readFile(path)); + } catch { + if (!_openskyCredentialsFileWarned) { + console.warn( + '[OpenSky] OPENSKY_CREDENTIALS_FILE could not be read or parsed as JSON.', + ); + _openskyCredentialsFileWarned = true; + } + return null; + } + const fileId = String(raw?.clientId ?? raw?.client_id ?? '').trim(); + const fileSecret = String( + raw?.clientSecret ?? raw?.client_secret ?? '', + ).trim(); + return fileId && fileSecret + ? { clientId: fileId, clientSecret: fileSecret } + : null; +} + /** * Obtain a valid OpenSky OAuth2 bearer token, refreshing if needed. * @@ -174,9 +222,9 @@ export async function getOpenSkyToken() { // return the same promise instead of issuing a duplicate token request if (_openskyTokenPromise) return _openskyTokenPromise; - const clientId = process.env.OPENSKY_CLIENT_ID; - const clientSecret = process.env.OPENSKY_CLIENT_SECRET; - if (!clientId || !clientSecret) return null; + const credentials = openSkyClientCredentials(); + if (!credentials) return null; + const { clientId, clientSecret } = credentials; // Wrap the async token fetch in a shared promise stored in _openskyTokenPromise _openskyTokenPromise = (async () => { @@ -417,8 +465,10 @@ function openSkySourceIsStale(sourceEpochMs, now = Date.now()) { * * Supports four auth modes controlled by OPENSKY_AUTH_MODE env: * - 'oauth' (default) — client_credentials bearer token - * - 'basic' — HTTP Basic with OPENSKY_USERNAME / OPENSKY_PASSWORD - * - 'auto' — try OAuth first, fall back to Basic, then anon + * - 'basic' — retired: OpenSky stopped accepting HTTP Basic (username and + * password) on 2026-03-18, so this requests anonymously and + * warns once + * - 'auto' — try OAuth first, then anon * - 'anon' — no credentials * * Successful responses are cached for OPENSKY_CACHE_MS (~9 s). On @@ -524,21 +574,21 @@ export function openSkyProxy() { _openskySnapshotInFlight, 'global', async () => { - const basicUser = process.env.OPENSKY_USERNAME || ''; - const basicPass = process.env.OPENSKY_PASSWORD || ''; - const hasBasicCreds = Boolean(basicUser && basicPass); const headers = { Accept: 'application/json' }; let usedMode = 'anon'; let reason = 'forced_anonymous'; if (requestedMode === 'basic') { - if (hasBasicCreds) { - headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`; - usedMode = 'basic'; - reason = 'basic_credentials'; - } else { - reason = 'missing_basic_creds'; + // OpenSky no longer accepts a Basic header, so sending + // OPENSKY_USERNAME / OPENSKY_PASSWORD would only fail; ask + // anonymously and say once what to configure instead. + if (!_openskyBasicRetiredWarned) { + console.warn( + '[OpenSky] OPENSKY_AUTH_MODE=basic: OpenSky retired username/password sign-in on 2026-03-18, so flights load anonymously (rate-limited). Use OAuth client credentials: OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET, or OPENSKY_CREDENTIALS_FILE.', + ); + _openskyBasicRetiredWarned = true; } + reason = 'basic_retired'; } else if (requestedMode === 'oauth') { const token = await getOpenSkyToken(); if (token) { @@ -554,31 +604,12 @@ export function openSkyProxy() { headers.Authorization = `Bearer ${token}`; usedMode = 'oauth'; reason = 'oauth_token'; - } else if (hasBasicCreds) { - headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`; - usedMode = 'basic'; - reason = 'oauth_unavailable_fallback_basic'; } else { - reason = 'missing_oauth_and_basic_creds'; + reason = 'missing_oauth_creds'; } } - let { upstream, body } = await fetchOpenSkyStates(headers); - // Auto-mode fallback: if OAuth was rejected, retry with Basic credentials - if ( - (upstream.status === 401 || upstream.status === 403) && - requestedMode === 'auto' && - usedMode === 'oauth' && - hasBasicCreds - ) { - const retryHeaders = { - Accept: 'application/json', - Authorization: `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`, - }; - ({ upstream, body } = await fetchOpenSkyStates(retryHeaders)); - usedMode = 'basic'; - reason = 'oauth_rejected_fallback_basic'; - } + const { upstream, body } = await fetchOpenSkyStates(headers); const sourceEpochMs = upstream.ok ? openSkySourceEpochMs(body) @@ -586,12 +617,6 @@ export function openSkyProxy() { // Refine the reason string to reflect the actual outcome if (upstream.ok && reason === 'forced_anonymous') { reason = 'anonymous_ok'; - } else if ( - upstream.ok && - usedMode === 'basic' && - reason === 'basic_credentials' - ) { - reason = 'basic_ok'; } else if ( upstream.ok && usedMode === 'oauth' && @@ -627,12 +652,11 @@ export function openSkyProxy() { body, usedMode, reason, - hasBasicCreds, sourceEpochMs, }; }, ); - let { upstream, body, usedMode, reason, hasBasicCreds, sourceEpochMs } = + let { upstream, body, usedMode, reason, sourceEpochMs } = await refresh.promise; if ( upstream.ok && @@ -684,35 +708,24 @@ export function openSkyProxy() { } if (upstream.status === 401 || upstream.status === 403) { - if (requestedMode === 'basic' && !hasBasicCreds) { + if (requestedMode === 'oauth' && usedMode !== 'oauth') { body = JSON.stringify({ error: - 'OpenSky auth missing. Basic mode requires OPENSKY_USERNAME and OPENSKY_PASSWORD.', - }); - reason = 'missing_basic_creds'; - } else if (requestedMode === 'oauth' && usedMode !== 'oauth') { - body = JSON.stringify({ - error: - 'OpenSky auth invalid. OAuth mode requires valid OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET.', + 'OpenSky auth invalid. OAuth mode requires valid OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET, or OPENSKY_CREDENTIALS_FILE.', }); reason = 'oauth_invalid_or_missing'; - } else if (usedMode === 'basic') { - body = JSON.stringify({ - error: 'OpenSky auth invalid. Username/password were rejected.', - }); - reason = 'basic_invalid_credentials'; } else if (usedMode === 'oauth') { body = JSON.stringify({ error: 'OpenSky auth invalid. OAuth client credentials were rejected.', }); reason = 'oauth_invalid_credentials'; - } else if (requestedMode === 'auto' && !hasBasicCreds) { + } else if (requestedMode === 'auto') { body = JSON.stringify({ error: - 'OpenSky auth missing. Provide basic credentials or valid OAuth client credentials.', + 'OpenSky auth missing. Provide OAuth client credentials: OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET, or OPENSKY_CREDENTIALS_FILE.', }); - reason = 'missing_oauth_and_basic_creds'; + reason = 'missing_oauth_creds'; } else { body = JSON.stringify({ error: 'OpenSky auth required.', diff --git a/server/providers/aircraft/openskyCredentials.test.mjs b/server/providers/aircraft/openskyCredentials.test.mjs new file mode 100644 index 000000000..539579517 --- /dev/null +++ b/server/providers/aircraft/openskyCredentials.test.mjs @@ -0,0 +1,81 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import os from 'node:os'; +import path from 'node:path'; +import { openSkyClientCredentials } from './opensky.js'; + +const file = (contents) => (filePath) => { + file.read = filePath; + if (contents instanceof Error) throw contents; + return contents; +}; + +test('OpenSky client env vars come first', () => { + const read = () => assert.fail('the file is not read when env vars are set'); + assert.deepEqual( + openSkyClientCredentials( + { + OPENSKY_CLIENT_ID: ' id ', + OPENSKY_CLIENT_SECRET: 'secret', + OPENSKY_CREDENTIALS_FILE: 'credentials.json', + }, + read, + ), + { clientId: 'id', clientSecret: 'secret' }, + ); +}); + +test('OPENSKY_CREDENTIALS_FILE supplies the client when env vars are empty', () => { + const env = { + OPENSKY_CLIENT_ID: '', + OPENSKY_CREDENTIALS_FILE: '~/Downloads/credentials.json', + }; + // The file OpenSky's account page downloads uses camelCase keys. + assert.deepEqual( + openSkyClientCredentials( + env, + file('{"clientId":"cid","clientSecret":"csecret"}'), + ), + { clientId: 'cid', clientSecret: 'csecret' }, + ); + assert.equal( + path.normalize(file.read), + path.normalize(path.join(os.homedir(), 'Downloads/credentials.json')), + ); + assert.deepEqual( + openSkyClientCredentials( + env, + file('{"client_id":"cid","client_secret":"csecret"}'), + ), + { clientId: 'cid', clientSecret: 'csecret' }, + ); +}); + +test('a missing, unreadable or incomplete credentials file means no client', () => { + assert.equal(openSkyClientCredentials({}, file('{}')), null); + const env = { OPENSKY_CREDENTIALS_FILE: 'credentials.json' }; + const missing = Object.assign(new Error('nope'), { code: 'ENOENT' }); + assert.equal(openSkyClientCredentials(env, file(missing)), null); + assert.equal(openSkyClientCredentials(env, file('not json')), null); + assert.equal(openSkyClientCredentials(env, file('{"clientId":"cid"}')), null); +}); + +test('credential file warnings never expose file contents or read errors', async (t) => { + const warnings = []; + t.mock.method(console, 'warn', (message) => warnings.push(message)); + for (const contents of [ + '{"clientId":"id","clientSecret":FAKE_SECRET_123}', + new Error('could not read FAKE_SECRET_123'), + ]) { + const { openSkyClientCredentials: resolve } = await import( + `./opensky.js?warning=${warnings.length}` + ); + const env = { OPENSKY_CREDENTIALS_FILE: '/synthetic/credentials.json' }; + assert.equal(resolve(env, file(contents)), null); + assert.equal(resolve(env, file(contents)), null); + } + assert.deepEqual(warnings, [ + '[OpenSky] OPENSKY_CREDENTIALS_FILE could not be read or parsed as JSON.', + '[OpenSky] OPENSKY_CREDENTIALS_FILE could not be read or parsed as JSON.', + ]); +}); diff --git a/src/app/data.js b/src/app/data.js index ce3ad0731..e6c7d25e0 100644 --- a/src/app/data.js +++ b/src/app/data.js @@ -13,6 +13,7 @@ export function createApplicationData({ // Initialize data layer manager const dataManager = new LayerLifecycle(viewer, { allowQaRegistration, + visibilityTarget: document, getSourceAvailability: catalog?.getSourceAvailability, }); defer(async () => { diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index 0df5e87a4..c8946e7bb 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -72,13 +72,20 @@ function refreshFailureFromStats(stats, label) { /** * LayerLifecycle — Manages registration, toggling, and update loops * for real-time data overlays on the CesiumJS globe. + * Hosts may inject a visibilityTarget with hidden/visibilityState and event + * listener methods. Without one, periodic work retains its headless behavior. */ export class LayerLifecycle { constructor( viewer, - { allowQaRegistration = false, getSourceAvailability } = {}, + { + allowQaRegistration = false, + getSourceAvailability, + visibilityTarget = null, + } = {}, ) { this._catalogSourceAvailability = getSourceAvailability; + this._visibilityTarget = visibilityTarget; this.viewer = viewer; this._activityListeners = new Set(); this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain } @@ -528,6 +535,13 @@ export class LayerLifecycle { } } + _stopUpdateLoop(entry) { + clearInterval(entry.intervalId); + entry.intervalId = null; + entry.removeVisibilityListener?.(); + entry.removeVisibilityListener = null; + } + _armUpdateLoop(layerId, entry) { const configuredRefreshInterval = Number(entry.module.refreshInterval); const updateInterval = Number(entry.module.updateInterval); @@ -538,9 +552,45 @@ export class LayerLifecycle { ? updateInterval : 0; if (refreshInterval > 0) { - entry.intervalId = setInterval(() => { + const visibilityDocument = this._visibilityTarget; + let intervalId = null; + let dueAt = 0; + const canPoll = () => + !visibilityDocument?.hidden && + visibilityDocument?.visibilityState !== 'hidden' && + entry.enabled && + !entry.destroying && + !entry.refreshing && + entry.intervalId === intervalId; + // dueAt and the interval phase share one origin, so every interval tick + // is a due poll. A visibility catch-up re-arms the interval at the new + // deadline; otherwise the old phase would skip its next tick. + const schedule = () => { + clearInterval(intervalId); + dueAt = Date.now() + refreshInterval; + intervalId = setInterval(onInterval, refreshInterval); + entry.intervalId = intervalId; + }; + const onInterval = () => { + if (!canPoll()) return; + dueAt = Date.now() + refreshInterval; void this._runPeriodicUpdate(layerId, entry); - }, refreshInterval); + }; + const onVisibilityChange = () => { + if (!canPoll() || Date.now() < dueAt) return; + schedule(); + void this._runPeriodicUpdate(layerId, entry); + }; + schedule(); + visibilityDocument?.addEventListener?.( + 'visibilitychange', + onVisibilityChange, + ); + entry.removeVisibilityListener = () => + visibilityDocument?.removeEventListener?.( + 'visibilitychange', + onVisibilityChange, + ); } else if (updateInterval === 0) { entry.intervalId = setInterval(() => { if (!entry.enabled) return; @@ -772,8 +822,7 @@ export class LayerLifecycle { entry.lifecycleUncertain = !compensated && !cleanupConfirmed; settleLifecycle(); if (!entry.enabled && entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; + this._stopUpdateLoop(entry); } this._publishActivity({ type: 'status' }); if (!compensated) { @@ -833,8 +882,7 @@ export class LayerLifecycle { } if (signal?.aborted) return finishCancelledDisable('disable'); if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; + this._stopUpdateLoop(entry); } entry.enabled = false; entry.lifecycleUncertain = false; @@ -845,8 +893,7 @@ export class LayerLifecycle { let abortCleanup = null; const cancelEnable = () => { if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; + this._stopUpdateLoop(entry); } // Disable immediately so modules with their own AbortController (Radio) // cancel pending update work at the same turn boundary. A second @@ -876,8 +923,7 @@ export class LayerLifecycle { // a later abort cannot revoke a successful retry. signal?.removeEventListener('abort', cancelEnable); if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; + this._stopUpdateLoop(entry); } await abortCleanup; let cleanupConfirmed = false; @@ -918,8 +964,7 @@ export class LayerLifecycle { }; const finishFailedEnable = async (phase, error) => { if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; + this._stopUpdateLoop(entry); } let cleanupConfirmed = false; try { @@ -1029,8 +1074,7 @@ export class LayerLifecycle { // Always clear any stale interval before assigning a new one, so we never // orphan a running timer and end up double-polling. if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; + this._stopUpdateLoop(entry); } // Manager-owned periodic refresh work has one normalized loading/error @@ -2158,8 +2202,7 @@ export class LayerLifecycle { return false; } if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; + this._stopUpdateLoop(entry); } entry.enabled = false; } diff --git a/src/data/manager.js b/src/data/manager.js index b719b5934..79bb7d9ae 100644 --- a/src/data/manager.js +++ b/src/data/manager.js @@ -5,7 +5,7 @@ export { layerFeedState } from '../ui/layers.js'; /** Compatibility facade for callers that construct a manager with its panel. */ export class DataLayerManager extends LayerLifecycle { constructor(viewer, options) { - super(viewer, options); + super(viewer, { visibilityTarget: globalThis.document, ...options }); this._presentation = new LayerPresentation(this); } buildTogglePanel(container) { diff --git a/src/data/manager.test.mjs b/src/data/manager.test.mjs index 23641c962..b1384a2de 100644 --- a/src/data/manager.test.mjs +++ b/src/data/manager.test.mjs @@ -3492,3 +3492,92 @@ test('refreshLayerStats reaches presentation through the bare lifecycle', async lifecycle.refreshLayerStats(); assert.deepEqual(changes, ['status']); }); + +test('periodic polls pause hidden, catch up once when due, and dispose visibility listeners', async (t) => { + const listeners = new Set(); + const doc = { + hidden: false, + addEventListener: (_name, fn) => listeners.add(fn), + removeEventListener: (_name, fn) => listeners.delete(fn), + }; + t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 }); + const { LayerLifecycle } = await import('./lifecycle.js'); + const manager = new LayerLifecycle({}, { visibilityTarget: doc }); + const { module, calls } = makeSlowLayer('hidden-poll'); + manager.register(module); + await manager.setEnabled('hidden-poll', true); + assert.equal(calls.update, 1); + doc.hidden = true; + t.mock.timers.tick(10000); + assert.equal(calls.update, 1); + assert.equal(await manager.refreshLayer('hidden-poll'), true); + assert.equal(calls.update, 2, 'explicit refresh remains allowed'); + doc.hidden = false; + for (const fn of listeners) fn(); + for (const fn of listeners) fn(); + assert.equal(calls.update, 3); + await Promise.resolve(); + await Promise.resolve(); + await manager.destroyAll(); + assert.equal(listeners.size, 0); + t.mock.timers.tick(10000); + assert.equal(calls.update, 3); +}); + +async function resumeBetweenTicks(t, { failResume = false } = {}) { + const listeners = new Set(); + const doc = { + hidden: false, + addEventListener: (_name, fn) => listeners.add(fn), + removeEventListener: (_name, fn) => listeners.delete(fn), + }; + t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 }); + const { LayerLifecycle } = await import('./lifecycle.js'); + const manager = new LayerLifecycle({}, { visibilityTarget: doc }); + const { module, calls } = makeSlowLayer('resume-poll'); + const updates = []; + module.update = async () => { + calls.update++; + updates.push(Date.now()); + await Promise.resolve(); + if (failResume && updates.length === 2) throw new Error('resume failed'); + }; + manager.register(module); + await manager.setEnabled('resume-poll', true); + assert.deepEqual(updates, [1000]); + doc.hidden = true; + t.mock.timers.tick(1000); // 2000: due tick skipped while hidden + t.mock.timers.tick(500); // 2500: return between ticks + doc.hidden = false; + for (const fn of listeners) fn(); + assert.deepEqual(updates, [1000, 2500], 'overdue poll runs once on return'); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + const managerRefreshError = manager.layers.get('resume-poll').managerRefreshError; + if (failResume) assert.equal(managerRefreshError, 'resume failed'); + else assert.equal(managerRefreshError, null); + t.mock.timers.tick(999); // 3499: the old 3000 phase must not fire + assert.deepEqual(updates, [1000, 2500]); + t.mock.timers.tick(1); // 3500: one interval after the resume poll + assert.deepEqual(updates, [1000, 2500, 3500]); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + t.mock.timers.tick(1000); + assert.deepEqual(updates, [1000, 2500, 3500, 4500], 'new phase keeps one interval'); + await Promise.resolve(); + await Promise.resolve(); + await manager.destroyAll(); + assert.equal(listeners.size, 0); + t.mock.timers.tick(5000); + assert.equal(updates.length, 4); +} + +test('periodic polls realign to the deadline after a between-ticks resume', async (t) => { + await resumeBetweenTicks(t); +}); + +test('periodic polls realign to the deadline after a failed resume refresh', async (t) => { + await resumeBetweenTicks(t, { failResume: true }); +}); diff --git a/src/data/placeProviderPayloads.js b/src/data/placeProviderPayloads.js index 37fd99ef7..8adf935a9 100644 --- a/src/data/placeProviderPayloads.js +++ b/src/data/placeProviderPayloads.js @@ -13,8 +13,11 @@ function approximateDistanceM(latA, lonA, latB, lonB) { return Number.MAX_SAFE_INTEGER; const latitudeScale = 111320; const longitudeScale = latitudeScale * Math.cos((latA * Math.PI) / 180); + // The short way round, so places either side of 180° stay close. + let dLon = lonB - lonA; + dLon -= 360 * Math.round(dLon / 360); return Math.round( - Math.hypot((latB - latA) * latitudeScale, (lonB - lonA) * longitudeScale), + Math.hypot((latB - latA) * latitudeScale, dLon * longitudeScale), ); } diff --git a/src/layers/transit/lifecycle.js b/src/layers/transit/lifecycle.js index 11b670519..90210063a 100644 --- a/src/layers/transit/lifecycle.js +++ b/src/layers/transit/lifecycle.js @@ -97,6 +97,7 @@ export function createLifecycle({ state, services, parts }) { else if (hidden) { hidden = false; parts.rendering.resumePlayback(); + parts.viewport.runProximityCheck(); } } function bindVisibility() { diff --git a/src/layers/transit/viewport.js b/src/layers/transit/viewport.js index 641fab5a2..730126f10 100644 --- a/src/layers/transit/viewport.js +++ b/src/layers/transit/viewport.js @@ -75,6 +75,11 @@ export function createViewport({ state, services, parts }) { } function runProximityCheck() { + if ( + globalThis.document?.hidden || + globalThis.document?.visibilityState === 'hidden' + ) + return; if (!state._enabled || !state._viewer) return; refreshViewBounds(); const altitude = getCameraAltitude(); diff --git a/src/setupDoctor.test.mjs b/src/setupDoctor.test.mjs index 26d695ba1..d4a2a7ca7 100644 --- a/src/setupDoctor.test.mjs +++ b/src/setupDoctor.test.mjs @@ -13,6 +13,7 @@ import { npmProcessSpec, readDoctorDotenvValue, resolveOpenSkyAuthMode, + resolveOpenSkyCredentialsFile, resolveCredential, } from '../scripts/setup-doctor.mjs'; @@ -237,7 +238,7 @@ test('doctor retains OAuth capability wording for a complete client pair', () => ); }); -test('doctor reports Basic mode without inferring runtime auth from OAuth credentials', () => { +test('doctor reports retired Basic mode as anonymous, whatever OAuth credentials exist', () => { const oauthCredentials = { OPENSKY_CLIENT_ID: { configured: true }, OPENSKY_CLIENT_SECRET: { configured: true }, @@ -245,7 +246,7 @@ test('doctor reports Basic mode without inferring runtime auth from OAuth creden for (const credentials of [{}, oauthCredentials]) { assert.equal( buildCapabilitySummary(credentials, { openSkyAuthMode: 'basic' }).flights, - 'OpenSky Basic mode selected (credential presence and validity not verified)', + 'OpenSky Basic mode selected: OpenSky retired username/password sign-in, so flights load anonymously (rate-limited)', ); } }); @@ -393,3 +394,39 @@ test('doctor never calls a dependency-missing setup ready', () => { assert.match(output, /Setup needs attention/); assert.doesNotMatch(output, /Ready\. Run/); }); + +test('doctor counts OPENSKY_CREDENTIALS_FILE as OAuth credentials, from the environment or .env', () => { + assert.equal( + buildCapabilitySummary({}, { openSkyCredentialsFile: true }).flights, + 'OpenSky OAuth credentials file set (OPENSKY_CREDENTIALS_FILE; file contents and validity not verified)', + ); + assert.equal( + buildCapabilitySummary({}, { openSkyCredentialsFile: false }).flights, + 'OpenSky keyless anonymous access (rate-limited)', + ); + const root = mkdtempSync(path.join(tmpdir(), 'gev-doctor-opensky-file-')); + try { + assert.equal( + resolveOpenSkyCredentialsFile({ environment: {}, rootDir: root }), + false, + ); + writeFileSync( + path.join(root, '.env'), + 'OPENSKY_CREDENTIALS_FILE=~/Downloads/credentials.json\n', + ); + assert.equal( + resolveOpenSkyCredentialsFile({ environment: {}, rootDir: root }), + true, + ); + assert.equal( + resolveOpenSkyCredentialsFile({ + authoritativeEnvironment: true, + environment: { OPENSKY_CREDENTIALS_FILE: '' }, + rootDir: root, + }), + false, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/sources/featureGeometry.js b/src/sources/featureGeometry.js index a18d5f22f..b2ccc9630 100644 --- a/src/sources/featureGeometry.js +++ b/src/sources/featureGeometry.js @@ -250,10 +250,13 @@ export function approximateAreaM2(coords) { return Math.abs(area) / 2; } +/** Local equirectangular distance in metres, the short way round 180°. */ export function approximateDistanceM(latA, lonA, latB, lonB) { const latScale = 111_320; const lonScale = latScale * Math.cos(((latA + latB) / 2) * (Math.PI / 180)); - return Math.hypot((latB - latA) * latScale, (lonB - lonA) * lonScale); + let dLon = lonB - lonA; + dLon -= 360 * Math.round(dLon / 360); + return Math.hypot((latB - latA) * latScale, dLon * lonScale); } export function pointInPolygon(lon, lat, coords) { diff --git a/src/sources/featureGeometry.test.mjs b/src/sources/featureGeometry.test.mjs new file mode 100644 index 000000000..6da3ff92f --- /dev/null +++ b/src/sources/featureGeometry.test.mjs @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { approximateDistanceM } from './featureGeometry.js'; + +test('approximate distance takes the short way round 180°', () => { + // Lau Islands (-179.0) to Savusavu (179.33): about 178 km apart. + const across = approximateDistanceM(-17.5, -179.0, -16.78, 179.33); + assert.ok(across > 150_000 && across < 200_000, String(across)); + assert.equal( + Math.round(approximateDistanceM(0, 179.9, 0, -179.9)), + Math.round(approximateDistanceM(0, -0.1, 0, 0.1)), + ); + // Away from 180° nothing changes. + assert.equal(Math.round(approximateDistanceM(0, 0, 0, 1)), 111_320); +}); diff --git a/src/sources/live/standalone.js b/src/sources/live/standalone.js index 3f2c73b3b..d76ecf3fa 100644 --- a/src/sources/live/standalone.js +++ b/src/sources/live/standalone.js @@ -31,9 +31,7 @@ function openSkyError(response) { const reasons = { oauth_invalid_or_missing: 'OpenSky OAuth client missing/invalid', oauth_invalid_credentials: 'OpenSky OAuth rejected credentials', - basic_invalid_credentials: 'OpenSky username/password rejected', - missing_basic_creds: 'OpenSky auth missing', - missing_oauth_and_basic_creds: 'OpenSky auth missing', + missing_oauth_creds: 'OpenSky auth missing', auth_required: 'OpenSky auth required', forced_anonymous: 'OpenSky auth required', }; diff --git a/src/tooling/liveProviders.test.mjs b/src/tooling/liveProviders.test.mjs index 6b880d186..1f1befc95 100644 --- a/src/tooling/liveProviders.test.mjs +++ b/src/tooling/liveProviders.test.mjs @@ -584,6 +584,69 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches assert.equal(callCount, 1); }); +test('OpenSky Basic mode no longer sends username and password: it asks anonymously and warns once', async (t) => { + environment(t, { + OPENSKY_CLIENT_ID: undefined, + OPENSKY_CLIENT_SECRET: undefined, + OPENSKY_CREDENTIALS_FILE: undefined, + OPENSKY_AUTH_MODE: 'basic', + OPENSKY_USERNAME: 'fixture-user', + OPENSKY_PASSWORD: 'fixture-password', + }); + const warnings = []; + t.mock.method(console, 'warn', (message) => warnings.push(message)); + const authorizations = []; + t.mock.method(globalThis, 'fetch', async (url, options) => { + if (url.includes('/states/')) { + authorizations.push(options.headers.Authorization); + return Response.json({ time: Math.floor(Date.now() / 1000), states: [] }); + } + throw Error(`Unexpected URL: ${url}`); + }); + const fresh = await import( + `../../server/providers/aircraft/opensky.js?basic=${Math.random()}` + ); + const flights = install(fresh.openSkyProxy()); + const first = await flights('/api/flights'); + assert.equal(first.statusCode, 200); + assert.equal(first.headers['x-opensky-auth'], 'anon'); + assert.equal(first.headers['x-opensky-auth-reason'], 'basic_retired'); + assert.deepEqual(authorizations, [undefined]); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /retired username\/password sign-in/); + assert.ok(!warnings[0].includes('fixture-password')); +}); + +test('OpenSky auto mode never falls back to Basic, even when OAuth is rejected', async (t) => { + environment(t, { + OPENSKY_CLIENT_ID: 'fixture-client', + OPENSKY_CLIENT_SECRET: 'fixture-secret', + OPENSKY_CREDENTIALS_FILE: undefined, + OPENSKY_AUTH_MODE: 'auto', + OPENSKY_USERNAME: 'fixture-user', + OPENSKY_PASSWORD: 'fixture-password', + }); + t.mock.method(console, 'warn', () => {}); + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'error', () => {}); + const authorizations = []; + t.mock.method(globalThis, 'fetch', async (url, options) => { + if (url.includes('/token')) + return Response.json({ access_token: 'fixture-token', expires_in: 1800 }); + if (url.includes('/states/')) { + authorizations.push(options.headers.Authorization); + return new Response('', { status: 401 }); + } + if (url.includes('/lat/')) return regionalFallback(); + throw Error(`Unexpected URL: ${url}`); + }); + const fresh = await import( + `../../server/providers/aircraft/opensky.js?auto=${Math.random()}` + ); + await install(fresh.openSkyProxy())('/api/flights', '?lat=30&lon=-97'); + assert.deepEqual(authorizations, ['Bearer fixture-token']); +}); + test('retired military history refuses without upstream I/O while validating identifiers', async (t) => { t.mock.method(globalThis, 'fetch', async () => { assert.fail('retired history must not contact the upstream'); diff --git a/src/tooling/placeProviders.test.mjs b/src/tooling/placeProviders.test.mjs index 352cc8824..d533b7fb9 100644 --- a/src/tooling/placeProviders.test.mjs +++ b/src/tooling/placeProviders.test.mjs @@ -81,6 +81,24 @@ test('nearby labels rank landmarks, deduplicate names/addresses and bound the pr assert.deepEqual(projectNearbyPlaces({}, 0, 0), []); }); +test('nearby place distances take the short way round 180°', () => { + const at = (name, longitude) => ({ + id: name, + displayName: { text: name }, + location: { latitude: -16.8, longitude }, + types: ['point_of_interest'], + }); + // From Taveuni at 179.98: 2 km east across 180°, 4 km west on this side. + const result = projectNearbyPlaces( + { places: [at('West', 179.942), at('East', -179.999)] }, + -16.8, + 179.98, + ); + const byName = Object.fromEntries(result.map((p) => [p.name, p.distanceM])); + assert.ok(byName.East > 1500 && byName.East < 2500, String(byName.East)); + assert.ok(byName.West > 3500 && byName.West < 4500, String(byName.West)); +}); + test('text search preserves bounds, rejects malformed bounds and tolerates absent locations', () => { const viewport = { low: { latitude: 1, longitude: 2 }, diff --git a/src/tooling/transitQa.test.mjs b/src/tooling/transitQa.test.mjs index 51e4d5c44..634538c2a 100644 --- a/src/tooling/transitQa.test.mjs +++ b/src/tooling/transitQa.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; +import { getTransitFeed } from '../data/transitFeeds.js'; import { chooseLiveCities, selectStyle, @@ -101,6 +102,18 @@ test('live visual cities follow local operating hours in summer and winter', () const cities = chooseLiveCities(now); assert.ok(cities.length >= 1); for (const city of cities) { + const feed = getTransitFeed(city.feedId); + assert.ok(feed, `${city.id} has a registered feed`); + const distanceKm = + 111.32 * + Math.hypot( + feed.center.lat - city.lat, + (feed.center.lon - city.lon) * Math.cos((city.lat * Math.PI) / 180), + ); + assert.ok( + distanceKm < feed.loadRadiusKm, + `${city.id} is inside its feed's admission region`, + ); const local = Number( new Intl.DateTimeFormat('en', { timeZone: city.zone,