diff --git a/Dockerfile b/Dockerfile index d34f4887a..433d170a8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,6 +39,11 @@ COPY --chown=node:node . . ENV HOST=0.0.0.0 ENV PORT=4173 EXPOSE 4173 +# node:slim has no curl, so probe with Node's built-in fetch. 127.0.0.1 is +# always on the Host allowlist, and the generous start period covers Vite's +# first-run dependency optimisation. +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD ["node", "-e", "fetch('http://127.0.0.1:'+(Number.parseInt(process.env.PORT,10)||4173)+'/').then((r)=>process.exit(r.ok?0:1),()=>process.exit(1))"] # The launcher is `npm run dev` plus one step: it trusts the container's # gateway address for Provider Settings, because that is where Docker delivers # the host's own browser from (never 127.0.0.1). See scripts/docker-start.mjs. diff --git a/README.md b/README.md index 80f052571..0a7509efa 100644 --- a/README.md +++ b/README.md @@ -111,13 +111,15 @@ app's **POWER UP** panel; [Keys & Costs](#-api-keys) explains the options. Available on **Windows, macOS, and Linux**. The Pinokio maintainer reports cross-platform testing of the fixed installer. The launcher installs the -locked dependencies, finds a free local port, and opens the app. +locked dependencies, finds a free local port, and opens the app in your system +browser. The Server menu item keeps the launcher console available. **Tried before and installation failed?** Update Pinokio and try again. Version 8.2 fixes the launcher installation issue; [details from the Pinokio maintainer](https://pinokio.co/posts/01m1m4p9xxm3qw7dnnpj2wr93g). -**Pinokio opens but the globe is very choppy?** Before treating that as a GPU +**Using an older launcher or opening the embedded view manually?** If the globe +is very choppy, before treating that as a GPU compatibility or minimum-hardware problem, copy the local server URL shown by Pinokio and open the same URL in your normal hardware-accelerated browser. One Pinokio 8.2.0 user reported severe stutter in Pinokio's embedded view that went @@ -207,6 +209,8 @@ env UID=1026 GID=100 docker compose up -d Keep the `env`: bash treats `UID` as read-only, so a plain `UID=1026` in front of a command is silently ignored. If the container can't write the checkout, it says so at start. Docker Desktop on macOS and Windows maps ownership for you. +On SELinux hosts (Fedora, RHEL), compose mounts the checkout with `:z` so the container may read it; Docker may relabel the checkout for container access, which can take a moment on a large checkout. Without that, the start fails with `Cannot find module '/app/scripts/docker-start.mjs'`. + Behind a reverse proxy, put your keys in `.env` or pass them as environment variables - Provider Settings refuses proxied requests on purpose - and add the hostname you serve it on, for example `GEV_ALLOWED_HOSTS=globe.example.lan` (see [Sharing an instance](#-sharing-an-instance)). diff --git a/SECURITY.md b/SECURITY.md index 1baad029f..4423eead3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,6 +2,11 @@ God's Eye View is a local-first client for **public** data. It is built for exploration, demos, and learning — not as a hardened production service. This document explains the security model so you can run it safely and report issues responsibly. +Vite's development file server also denies `.gev-logs/`, `.gev-cache/`, and +the package checkout's root `dist/` directory. Dependency `dist/` directories +remain available. This filesystem deny list does not filter `public/` assets or +preview output; never put private artifacts in a public asset directory. + ## Reporting a vulnerability Please report security issues **privately** — do not open a public issue for anything exploitable. diff --git a/build/vite.js b/build/vite.js index a02f2340f..960603814 100644 --- a/build/vite.js +++ b/build/vite.js @@ -1,9 +1,29 @@ +import { fileURLToPath } from 'node:url'; import { applicationHtmlPlugin } from './application-html.js'; import cesium from 'vite-plugin-cesium'; import { DEFAULT_ALLOWED_HOSTS, hostCheckPlugin } from './allowedHosts.js'; import { embedFramingPlugin } from './embed-framing.js'; import { panelBuildPlugin } from './panel.js'; +/** + * The repository root (this file lives at `/build/vite.js`), as a + * forward-slash path for glob matching. Vite's `fs.deny` patterns are + * matched against the full absolute file path, and a bare `**\/dist/**` + * matches a `dist` directory at *any* depth — including dependencies that + * ship their own, such as `node_modules/vite/dist/client/client.mjs` (the + * dev client itself). Anchoring to this repository's root denies only the + * project's own build output. + */ +const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url)) + .replace(/\\/g, '/') + .replace(/\/$/, ''); + +export function escapeFsDenyPath(path) { + return path.replace(/[()[\]{}*?!+@|]/g, '\\$&'); +} + +const REPO_ROOT_GLOB = escapeFsDenyPath(REPO_ROOT); + /** * Content-Security-Policy for every document the dev/preview server serves. * No inline script is permitted, and the only foreign script origins are the @@ -165,7 +185,19 @@ export function createBrowserViteConfig({ // build/allowedHosts.js); IP addresses are always accepted. allowedHosts: [...allowedHosts], fs: { - deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**', '**/ENVIRONMENT'], + deny: [ + '.env', + '.env.*', + '*.{crt,pem}', + '**/.git/**', + '**/ENVIRONMENT', + '**/.gev-logs/**', + '**/.gev-cache/**', + // Root-anchored: a bare '**/dist/**' would also deny every + // dependency's own dist/ (e.g. node_modules/vite/dist/client), + // breaking /@vite/client and other dev-client assets. + `${REPO_ROOT_GLOB}/dist/**`, + ], }, // These headers protect the document containing Provider Settings and // give the whole page a real Content-Security-Policy (BROWSER_CSP). diff --git a/changelog.d/1066-keyless-google-token.md b/changelog.d/1066-keyless-google-token.md new file mode 100644 index 000000000..c07cbade8 --- /dev/null +++ b/changelog.d/1066-keyless-google-token.md @@ -0,0 +1,3 @@ +- Keyless standalone startup answers an unavailable Google tile-token endpoint + with uncached 204 instead of a browser-visible 404. Existing providers take + precedence, and unrelated API routes behave as before. (dvilrgamerz, #1066) diff --git a/changelog.d/1222-pinokio-open-in-browser.md b/changelog.d/1222-pinokio-open-in-browser.md new file mode 100644 index 000000000..bc6b62402 --- /dev/null +++ b/changelog.d/1222-pinokio-open-in-browser.md @@ -0,0 +1,3 @@ +- Open God's Eye View from Pinokio in the system browser using the supported + `popout` menu option. The application's framing policy is unchanged + (NukeThemAII, #1222). diff --git a/changelog.d/1339-hud-dms-carry.md b/changelog.d/1339-hud-dms-carry.md new file mode 100644 index 000000000..588668afe --- /dev/null +++ b/changelog.d/1339-hud-dms-carry.md @@ -0,0 +1,3 @@ +- Carry HUD LAT/LON seconds that round up to 60.00" into the minutes and + degrees, so longitude -122.35 reads `122°21'00.00"W` instead of + `122°20'60.00"W` (rudycelekli, #1339). diff --git a/changelog.d/1345-weather-staging-wake.md b/changelog.d/1345-weather-staging-wake.md new file mode 100644 index 000000000..fec4d54ec --- /dev/null +++ b/changelog.d/1345-weather-staging-wake.md @@ -0,0 +1,3 @@ +- Wake staged weather imagery on a 150 ms timer while waiting for readiness, + avoiding a continuous render loop and cancelling the wake when work ends + (@nu-stats, #1345). diff --git a/changelog.d/1391-docker-selinux.md b/changelog.d/1391-docker-selinux.md new file mode 100644 index 000000000..186147a2e --- /dev/null +++ b/changelog.d/1391-docker-selinux.md @@ -0,0 +1,4 @@ +- Start the Docker install on SELinux hosts such as Fedora and RHEL. Compose now + mounts the checkout with `:z`; without it the container could not read `/app` + and stopped with "Cannot find module '/app/scripts/docker-start.mjs'" + (Anıl Güvenç, @anilguvenc, #1391). diff --git a/changelog.d/1394-docker-healthcheck.md b/changelog.d/1394-docker-healthcheck.md new file mode 100644 index 000000000..75ead9667 --- /dev/null +++ b/changelog.d/1394-docker-healthcheck.md @@ -0,0 +1,2 @@ +- Report Docker container health with a local HTTP check using Node, with the + same port parsing as the launcher (John Rogers, @14traits, #1394). diff --git a/changelog.d/948-vite-fs-deny.md b/changelog.d/948-vite-fs-deny.md new file mode 100644 index 000000000..642bafb1f --- /dev/null +++ b/changelog.d/948-vite-fs-deny.md @@ -0,0 +1,4 @@ +- Harden Vite's filesystem deny list for local-only artifacts: repo `dist/`, + `.gev-logs/`, and `.gev-cache/` are denied without blocking dependency + `dist/` directories, and checkout paths containing glob metacharacters are + escaped before building the root-anchored pattern (blowing-a-raspberry, #948). diff --git a/compose.yaml b/compose.yaml index c2d3bf3d4..38cf438ca 100644 --- a/compose.yaml +++ b/compose.yaml @@ -22,5 +22,8 @@ services: # UI land in the .env on your host instead of vanishing with the # container. node_modules stays in the image: sharp and friends are # compiled for Linux and must not be shadowed by a host install. - - .:/app + # `:z` relabels the checkout for containers on SELinux hosts (Fedora, + # RHEL); without it the container cannot read /app at all and Node + # reports the launcher as "Cannot find module". Elsewhere it is ignored. + - .:/app:z - /app/node_modules diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 777880ce1..ec582aba9 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,15 @@ # God's Eye View Current State +Docker Compose mounts the checkout with the shared SELinux `:z` label so +containers can read it on enforcing SELinux hosts. The separate image-owned +`node_modules` volume and loopback-only port publishing remain unchanged. + +The HUD's LAT/LON degrees-minutes-seconds readout rounds the coordinate once, to +hundredths of a second, and then splits it. A value within 0.005" of the next +whole minute carries into the minutes and degrees, so `-122.35` reads +`122°21'00.00"W` rather than `122°20'60.00"W`. The field widths and hemisphere +letters are unchanged. + Aircraft detection candidates reuse normalized display text until callsign or registration changes, including in-place enrichment. Each candidate resolves its fleet model once per collection. Positions, model ownership and tracked-aircraft @@ -296,6 +306,10 @@ catalog construction owns each instance. Wind animates one forecast without advancing forecast time. Separate Weather observation layers provide radar and satellite history; none claims measured cloud volume. Weather imagery releases its camera move listener on disable and registers it once on re-enable. Disabling also clears active imagery work. +A staged weather frame with loaded tiles and no pending requests rechecks +readiness on a single 150 ms wake timer instead of requesting every render. +The existing readiness conditions and two settled frames still govern promotion; +completion, cancellation, replacement and teardown clear the wake timer. Weather imagery drapes onto the globe basemap. On photorealistic 3D Tiles each observed product is instead a raised, translucent shell: one rectangle over the product bounds at a fixed height (global infrared 5.5 km, regional infrared @@ -1839,6 +1853,12 @@ supported. `.env.example` and `pinokio/_ENVIRONMENT` document both entries. The Street View headings tool uses the same server-first selection after resolving environment overrides per variable; its explicit `--key` wins. +In standalone development and preview servers, a missing Google tile-token +provider returns an uncached `204 No Content` for `GET /api/google/tiles-token` +(and HEAD) instead of a browser-visible 404. The fallback is registered after +real token providers and before the unknown-API handler, so existing Google +browser keys, provider-issued tokens, and unrelated API routes remain unchanged. + ## Infrastructure marker visibility Datacenter/dam registration uses fresh reusable factories with the application's @@ -5375,11 +5395,21 @@ pins and share links already supply complete boxes. ## Auth + Launch +Vite's development filesystem deny list excludes local `.gev-logs/` and +`.gev-cache/` contents and the package checkout's root `dist/`, while allowing +dependency `dist/` assets. Checkout glob characters are escaped. This does not +apply to `public/` assets or built-preview serving. + + - Recommended launcher: `./scripts/dev-fresh.sh` (also: `dev-secure.sh` for stricter bindings, `dev-cctv.sh` for CCTV source-pack tuning) - A successful Pinokio install writes the owner-only `pinokio/.installed` marker. The nested launcher menu resolves that marker from its own directory: an absent marker exposes Install, a present marker exposes Start, and a - running server with a captured ready URL exposes Open God's Eye View. + running server with a captured ready URL exposes Open God's Eye View. That + default item uses Pinokio's `popout` option to open the system browser; the + Server item retains the launcher console. The app's framing headers stay + unchanged. This does not assume all Pinokio desktop versions enforce those + headers in their embedded view. - Build gate: `npm run build` - Network access: local-only by default (`HOST=localhost` in dev-fresh.sh); LAN is an explicit opt-in via `HOST=0.0.0.0` (launcher prints a key-exposure warning + LAN URL; see SECURITY.md) - OpenSky default mode: OAuth (`OPENSKY_AUTH_MODE=oauth`; `anon` works without credentials) @@ -6231,3 +6261,12 @@ when their footprints touch or contain one another, never across a seam toleranc - `scripts/qa-form-fields.mjs` exercises native selections, number validity, calibration commit/cancel and stale-camera/disposal guards against an installed controller with fixture ports, plus default/Cyber and narrow translated layouts. + +## Docker health reporting + +The Docker image probes its local HTTP root with Node every 30 seconds, using +the launcher's parsed `PORT` (default 4173). A successful HTTP response marks +the container healthy. Checks time out after 10 seconds, with a 30-second +startup grace period and three consecutive failures before reporting unhealthy. +This reports app HTTP availability, not individual provider health, and does not +configure automatic restarts. diff --git a/pinokio/pinokio.js b/pinokio/pinokio.js index d781c1a58..49c42a361 100644 --- a/pinokio/pinokio.js +++ b/pinokio/pinokio.js @@ -22,8 +22,10 @@ module.exports = { if (starting) { const local = info.local('start.js'); if (local?.url) { + // Open in the system browser without changing the app's framing policy. + // This also avoids relying on Pinokio's embedded browser behavior. return [ - { default: true, icon: 'fa-solid fa-earth-americas', text: 'Open God\'s Eye View', href: local.url }, + { default: true, icon: 'fa-solid fa-earth-americas', text: 'Open God\'s Eye View', href: local.url, popout: true }, { icon: 'fa-solid fa-terminal', text: 'Server', href: 'start.js' }, ]; } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index d62e8bb47..20e28122a 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1087,5 +1087,6 @@ "scripts/qa-status-badges.mjs", "src/dockerTrustedPeers.test.mjs", "src/cameraGlobeRotation.test.mjs", - "src/voice/globeResetConfirmation.test.mjs" + "src/voice/globeResetConfirmation.test.mjs", + "src/hudDms.test.mjs" ] diff --git a/server/standalone/google-token-unavailable.js b/server/standalone/google-token-unavailable.js new file mode 100644 index 000000000..6089b5941 --- /dev/null +++ b/server/standalone/google-token-unavailable.js @@ -0,0 +1,27 @@ +/** + * Treat an unavailable Google Maps tile-token endpoint as a supported, silent + * outcome. Install after real token providers and before the unknown-API 404. + */ +export function googleTokenUnavailablePlugin() { + const install = (server) => { + server.middlewares.use((req, res, next) => { + const pathname = String(req.url || '').split('?')[0]; + if ( + (req.method !== 'GET' && req.method !== 'HEAD') || + pathname !== '/api/google/tiles-token' + ) { + next(); + return; + } + + res.writeHead(204, { 'Cache-Control': 'no-store' }); + res.end(); + }); + }; + + return { + name: 'google-token-unavailable', + configureServer: install, + configurePreviewServer: install, + }; +} diff --git a/server/standalone/vite.config.js b/server/standalone/vite.config.js index ffe5124ce..f095ec2ac 100644 --- a/server/standalone/vite.config.js +++ b/server/standalone/vite.config.js @@ -5,6 +5,7 @@ import { createBrowserViteConfig } from '../../build/vite.js'; import { localProviderPlugins } from '../providers/local.js'; import { localMcpPlugin } from '../mcp/plugin.js'; import { apiNotFoundPlugin } from './api-not-found.js'; +import { googleTokenUnavailablePlugin } from './google-token-unavailable.js'; import { standaloneVoiceTools } from './voiceTools.js'; import { unwatchedRuntimeDirsPlugin } from './unwatched-cache.js'; @@ -21,6 +22,7 @@ export default defineConfig(({ command, mode }) => { plugins: [ ...localProviderPlugins({ realtime: { tools }, gemini: { tools } }), localMcpPlugin(), + googleTokenUnavailablePlugin(), apiNotFoundPlugin(), unwatchedRuntimeDirsPlugin(), ], diff --git a/src/dockerLauncher.test.mjs b/src/dockerLauncher.test.mjs index 213fe59dc..4e97849c0 100644 --- a/src/dockerLauncher.test.mjs +++ b/src/dockerLauncher.test.mjs @@ -75,4 +75,5 @@ test('the Docker install path keeps its loopback-only, launcher-owned contract', assert.match(compose, /^\s+- GEV_KEY_SETUP_TRUSTED_PEERS$/m, 'the operator policy reaches the launcher without a hard-coded address'); assert.match(compose, /^\s+- GEV_ALLOWED_HOSTS$/m, 'the host allowlist reaches Vite'); assert.doesNotMatch(compose, /network_mode/, 'the container keeps its own network namespace'); + assert.match(compose, /^\s+- \.:\/app:z$/m, 'SELinux hosts can read the bind-mounted checkout'); }); diff --git a/src/hud.js b/src/hud.js index cf7983da7..1428f8503 100644 --- a/src/hud.js +++ b/src/hud.js @@ -574,11 +574,12 @@ export class IntelHUD { * @returns {string} Formatted DMS string, e.g. `"38°53'23.10"N"`. */ _toDMS(decimal, type) { - const abs = Math.abs(decimal); - const deg = Math.floor(abs); - const minFloat = (abs - deg) * 60; - const min = Math.floor(minFloat); - const sec = ((minFloat - min) * 60).toFixed(2); + // Round once, in hundredths of a second, so a value that rounds up to + // 60.00" carries into the minutes and degrees instead of printing 60. + const hundredths = Math.round(Math.abs(decimal) * 360000); + const deg = Math.floor(hundredths / 360000); + const min = Math.floor(hundredths / 6000) % 60; + const sec = ((hundredths % 6000) / 100).toFixed(2); let dir; if (type === 'lat') dir = decimal >= 0 ? 'N' : 'S'; diff --git a/src/hudDms.test.mjs b/src/hudDms.test.mjs new file mode 100644 index 000000000..064906b5f --- /dev/null +++ b/src/hudDms.test.mjs @@ -0,0 +1,106 @@ +// The HUD's LAT/LON readout in degrees-minutes-seconds. Seconds are printed to +// two decimals, so a value within 0.005" of the next whole minute rounds up. +// That carry must reach the minutes (and degrees): `122°20'60.00"W` is not a +// coordinate. Ordinary decimal inputs land there through binary floating +// point: 122.35 - 122 is 0.34999999999999432, so -122.35 read 20'60.00". +// +// hud.js imports `mgrs`, a CommonJS package whose named exports Node's ESM +// loader cannot see, so this installs the same one-specifier stub as +// hudAltitudeDatum.test.mjs before importing it. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; + +const MGRS_STUB_URL = 'gev-test-stub:mgrs'; +registerHooks({ + resolve(specifier, context, next) { + if (specifier === 'mgrs') return { url: MGRS_STUB_URL, shortCircuit: true }; + return next(specifier, context); + }, + load(url, context, next) { + if (url === MGRS_STUB_URL) { + return { + format: 'module', + shortCircuit: true, + source: + 'export function forward() { return "10SEG55776339"; }\nexport default { forward };\n', + }; + } + return next(url, context); + }, +}); + +const { IntelHUD } = await import('./hud.js'); + +const toDMS = (decimal, type) => IntelHUD.prototype._toDMS(decimal, type); + +test('a rounded-up second carries into the minutes', () => { + assert.equal(toDMS(-122.35, 'lon'), `122°21'00.00"W`); + assert.equal(toDMS(139.7, 'lon'), `139°42'00.00"E`); + assert.equal(toDMS(37.0166666, 'lat'), `37°01'00.00"N`); +}); + +test('a carry through 59 minutes reaches the degrees', () => { + assert.equal(toDMS(38.999999, 'lat'), `39°00'00.00"N`); + assert.equal(toDMS(-0.999999, 'lat'), `01°00'00.00"S`); + assert.equal(toDMS(179.999999, 'lon'), `180°00'00.00"E`); +}); + +test('ordinary values keep their existing format', () => { + assert.equal(toDMS(38.889722, 'lat'), `38°53'23.00"N`); + assert.equal(toDMS(-77.035278, 'lon'), `077°02'07.00"W`); + assert.equal(toDMS(51.5, 'lat'), `51°30'00.00"N`); + assert.equal(toDMS(-33.8568, 'lat'), `33°51'24.48"S`); + assert.equal(toDMS(0, 'lon'), `000°00'00.00"E`); + assert.equal(toDMS(90, 'lat'), `90°00'00.00"N`); +}); + +test('the telemetry tick paints the carried value into both readouts', () => { + const elements = new Map( + [ + 'hud-alt', + 'hud-summary', + 'hud-mgrs', + 'hud-latlon', + 'hud-bottom-line', + 'hud-gsd', + 'hud-coll', + 'hud-ona', + 'hud-mode', + ].map((id) => [id, { textContent: '' }]), + ); + const previousDocument = globalThis.document; + globalThis.document = { + getElementById: (id) => elements.get(id) ?? null, + querySelectorAll: () => [], + addEventListener() {}, + removeEventListener() {}, + }; + const viewer = { + camera: { + pitch: -Math.PI / 2, + positionCartographic: { + latitude: (37.75 * Math.PI) / 180, + longitude: (-122.35 * Math.PI) / 180, + height: 1500, + }, + computeViewRectangle: () => undefined, + moveStart: { addEventListener() {}, removeEventListener() {} }, + moveEnd: { addEventListener() {}, removeEventListener() {} }, + }, + }; + let hud; + try { + hud = new IntelHUD(viewer); + hud._updateCameraData(); + const latlon = elements.get('hud-latlon').textContent; + const bottom = elements.get('hud-bottom-line').textContent; + assert.equal(latlon, `37°45'00.00"N 122°21'00.00"W`); + assert.match(bottom, /LON: 122°21'00\.00"W/); + assert.doesNotMatch(`${latlon} ${bottom}`, /60\.00"/); + } finally { + hud?.destroy(); + if (previousDocument === undefined) delete globalThis.document; + else globalThis.document = previousDocument; + } +}); diff --git a/src/layers/weather/rendering.js b/src/layers/weather/rendering.js index b25bcceb2..0f2fad68b 100644 --- a/src/layers/weather/rendering.js +++ b/src/layers/weather/rendering.js @@ -14,6 +14,8 @@ const GLOBAL_TILE_MAXIMUM_LEVEL = 3; // Six decoded 2048×1024 canvases (8 MiB each) bound the cache below 50 MiB. const MAX_MOSAICS = 6; const MAX_PREFETCH_TILES = 8; +// How often a staged frame waiting out its quiet interval rechecks readiness. +export const READY_WAKE_MS = 150; /** Globe imagery: own at most a displayed and a staging frame. Use native Cesium * tile scheduling, projection and texture disposal; the application clock is never touched. */ @@ -126,6 +128,7 @@ function createGlobeRendering({ frame.offRender?.(); frame.offCamera?.(); clearTimeout(frame.timeout); + clearTimeout(frame.wake); for (const request of frame.requests) request.cancel?.(); frame.requests.clear(); frame.retries.clear(); @@ -277,6 +280,8 @@ function createGlobeRendering({ frame.offCamera?.(); frame.offCamera = null; clearTimeout(frame.timeout); + clearTimeout(frame.wake); + frame.wake = null; frame.offAbort?.(); if (ok) { lastError = null; @@ -450,8 +455,20 @@ function createGlobeRendering({ if (++settled >= 2) finish(true); else viewer.scene.requestRender(); } else settled = 0; - if (incoming === frame && frame.pending === 0 && frame.loaded > 0) - viewer.scene.requestRender(); + // Waiting out the quiet interval needs another readiness check + // later, not a render every frame: wake on a timer so the render + // governor's frame cap still holds while a frame is staged. + if ( + incoming === frame && + frame.pending === 0 && + frame.loaded > 0 && + !frame.wake + ) + frame.wake = setTimeout(() => { + frame.wake = null; + if (!frame.closed && incoming === frame) + viewer.scene.requestRender(); + }, READY_WAKE_MS); }); viewer.scene.requestRender(); onChange(); diff --git a/src/layers/weather/weather.test.mjs b/src/layers/weather/weather.test.mjs index 486d42520..a42485ed0 100644 --- a/src/layers/weather/weather.test.mjs +++ b/src/layers/weather/weather.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { createWeatherRendering } from './rendering.js'; +import { createWeatherRendering, READY_WAKE_MS } from './rendering.js'; import { createWeatherLayer } from './index.js'; import { createWeatherClock } from './clock.js'; import * as Cesium from 'cesium'; @@ -242,6 +242,50 @@ test('a ready weather frame does not wait for unrelated terrain, but requires su h.rendering.clear(); }); +test('a staged frame waiting out its quiet interval wakes on a timer, not every render', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + let now = 0; + const h = renderingHarness({ now: () => now }); + h.viewer.scene.globe.tilesLoaded = false; + const loaded = h.rendering.setFrame(snapshot, times[0]); + const tile = deferred(); + h.providers[0].response = tile; + const request = h.providers[0].requestImage(0, 0, 0, {}); + tile.resolve({}); + await request; + const before = h.renders(); + for (let i = 0; i < 10; i++) h.postRender.emit(); + assert.equal( + h.renders(), + before, + 'renders while waiting do not request further renders', + ); + t.mock.timers.tick(READY_WAKE_MS - 1); + assert.equal(h.renders(), before); + t.mock.timers.tick(1); + assert.equal(h.renders(), before + 1, 'one wake per interval'); + now = 200; + h.postRender.emit(); + h.postRender.emit(); + assert.equal(await loaded, true); + const settledRenders = h.renders(); + t.mock.timers.tick(READY_WAKE_MS * 4); + assert.equal(h.renders(), settledRenders, 'commit clears the wake timer'); + + const abandoned = h.rendering.setFrame(snapshot, times[1]); + const second = deferred(); + h.providers.at(-1).response = second; + const secondRequest = h.providers.at(-1).requestImage(0, 0, 0, {}); + second.resolve({}); + await secondRequest; + h.postRender.emit(); + h.rendering.clear(); + assert.equal(await abandoned, false); + const clearedRenders = h.renders(); + t.mock.timers.tick(READY_WAKE_MS * 4); + assert.equal(h.renders(), clearedRenders, 'clear cancels the wake timer'); +}); + test('camera movement drops abandoned deferred tiles but still waits for admitted requests', async () => { let now = 0; const h = renderingHarness({ now: () => now }); diff --git a/src/maps/googleTokens.js b/src/maps/googleTokens.js index 884c5b199..089730139 100644 --- a/src/maps/googleTokens.js +++ b/src/maps/googleTokens.js @@ -38,6 +38,7 @@ export function createGoogleTokenSource({ headers: { Accept: 'application/json' }, cache: 'no-store', }); + if (response.status === 204) return null; if (!response.ok) { await response.body?.cancel(); return null; diff --git a/src/maps/googleTokens.test.mjs b/src/maps/googleTokens.test.mjs index d8a1e84ed..10213db0c 100644 --- a/src/maps/googleTokens.test.mjs +++ b/src/maps/googleTokens.test.mjs @@ -79,3 +79,12 @@ test('a server that does not offer tokens yields none', async () => { assert.equal(await createGoogleTokenSource({ fetchImpl }).token(), null); } }); + +test('an empty 204 response explicitly means no Google token is available', async () => { + const { calls, fetchImpl } = server([ + new Response(null, { status: 204, headers: { 'Cache-Control': 'no-store' } }), + ]); + const tokens = createGoogleTokenSource({ fetchImpl }); + assert.equal(await tokens.token(), null); + assert.deepEqual(calls, [GOOGLE_TOKEN_PATH]); +}); diff --git a/src/pinokioLauncherContract.test.mjs b/src/pinokioLauncherContract.test.mjs index 24c9a9908..78748ff32 100644 --- a/src/pinokioLauncherContract.test.mjs +++ b/src/pinokioLauncherContract.test.mjs @@ -113,6 +113,9 @@ test('Pinokio menu resolves the nested install marker and exposes each lifecycle runtime.url = url; const items = await menu(kernel, info); assert.equal(items.filter((item) => item.default).length, 1); + const ready = installed && running === 'start.js' && Boolean(url); + assert.equal(items[0].popout, ready ? true : undefined); + assert.ok(items.slice(1).every((item) => item.popout === undefined)); return items.map(({ text, href, default: isDefault = false }) => ({ text, href, default: isDefault })); }; diff --git a/src/tooling/googleTokenUnavailable.test.mjs b/src/tooling/googleTokenUnavailable.test.mjs new file mode 100644 index 000000000..e0ee19232 --- /dev/null +++ b/src/tooling/googleTokenUnavailable.test.mjs @@ -0,0 +1,94 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { once } from 'node:events'; +import { createGoogleTokenSource } from '../maps/googleTokens.js'; +import { googleTokenUnavailablePlugin } from '../../server/standalone/google-token-unavailable.js'; + +async function withServer({ beforeFallback } = {}, check) { + const middlewares = []; + const app = { + middlewares: { + use: (handler) => middlewares.push(handler), + }, + }; + if (beforeFallback) app.middlewares.use(beforeFallback); + googleTokenUnavailablePlugin().configureServer(app); + app.middlewares.use((_req, res) => { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unknown API route' })); + }); + + const server = createServer((req, res) => { + let index = 0; + const next = () => middlewares[index++]?.(req, res, next); + next(); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + const url = 'http://127.0.0.1:' + address.port; + try { + await check(url); + } finally { + server.close(); + await once(server, 'close'); + } +} + +test('keyless GET and HEAD return uncached 204 instead of 404', async () => { + await withServer({}, async (url) => { + for (const method of ['GET', 'HEAD']) { + const response = await fetch(url + '/api/google/tiles-token?probe=1', { + method, + }); + assert.equal(response.status, 204); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal(await response.text(), ''); + } + }); +}); + +test('the existing client treats unavailable tokens as null', async () => { + await withServer({}, async (url) => { + const source = createGoogleTokenSource({ + fetchImpl: (path, options) => fetch(new URL(path, url), options), + }); + assert.equal(await source.token(), null); + }); +}); + +test('unrelated API paths and unsupported methods remain 404', async () => { + await withServer({}, async (url) => { + for (const [path, method] of [ + ['/api/google/tiles-token/extra', 'GET'], + ['/api/not-a-token', 'GET'], + ['/api/google/tiles-token', 'POST'], + ]) { + const response = await fetch(new URL(path, url), { method }); + assert.equal(response.status, 404); + } + }); +}); + +test('a real token provider registered first still wins', async () => { + await withServer( + { + beforeFallback: (req, res, next) => { + if (req.url !== '/api/google/tiles-token') return next(); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + accessToken: 'server-token', + expiresAt: 10000000000000, + }), + ); + }, + }, + async (url) => { + const response = await fetch(url + '/api/google/tiles-token'); + assert.equal(response.status, 200); + assert.equal((await response.json()).accessToken, 'server-token'); + }, + ); +}); diff --git a/src/tooling/viteBuild.test.mjs b/src/tooling/viteBuild.test.mjs index 7417f7145..a9e8c5398 100644 --- a/src/tooling/viteBuild.test.mjs +++ b/src/tooling/viteBuild.test.mjs @@ -1,3 +1,4 @@ +import { fileURLToPath } from 'node:url'; import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; @@ -5,7 +6,11 @@ import { rm, writeFile } from 'node:fs/promises'; import { request } from 'node:http'; import path from 'node:path'; import { build, createServer, preview } from 'vite'; -import { BROWSER_CSP, createBrowserViteConfig } from '../../build/vite.js'; +import { + BROWSER_CSP, + createBrowserViteConfig, + escapeFsDenyPath, +} from '../../build/vite.js'; import standaloneConfig, * as compatibility from '../../vite.config.js'; import * as providers from '../../server/providers/local.js'; import { apiNotFoundPlugin } from '../../server/standalone/api-not-found.js'; @@ -30,6 +35,20 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop assert.deepEqual(config.server.allowedHosts, ['localhost', '127.0.0.1']); assert.ok(config.server.fs.deny.includes('**/ENVIRONMENT')); assert.ok(config.server.fs.deny.includes('.env.*')); + assert.ok(config.server.fs.deny.includes('**/.gev-logs/**')); + assert.ok(config.server.fs.deny.includes('**/.gev-cache/**')); + const root = fileURLToPath(new URL('../../', import.meta.url)) + .replace(/\\/g, '/') + .replace(/\/$/, ''); + assert.deepEqual( + config.server.fs.deny.filter((pattern) => pattern.endsWith('/dist/**')), + [`${escapeFsDenyPath(root)}/dist/**`], + 'only project build output is denied, not dependency dist directories', + ); + assert.equal( + escapeFsDenyPath('C:/Users/a/project (1) [test]{copy}'), + 'C:/Users/a/project \\(1\\) \\[test\\]\\{copy\\}', + ); assert.equal(config.server.headers['X-Frame-Options'], 'DENY'); assert.equal(config.server.headers['X-Content-Type-Options'], 'nosniff'); const csp = config.server.headers['Content-Security-Policy']; @@ -98,7 +117,7 @@ test('root config retains existing named exports and standalone provider order', assert.equal(compatibility[name], value, name); const config = standaloneConfig({ mode: 'test' }); const standaloneProviders = config.plugins - .slice(4, -5) + .slice(4, -6) .map((plugin) => plugin.name); assert.equal( standaloneProviders.filter((name) => name === 'gemini-live-proxy').length, @@ -112,9 +131,10 @@ test('root config retains existing named exports and standalone provider order', standaloneProviders.filter((name) => name !== 'gemini-live-proxy'), providers.localProviderPlugins().map((plugin) => plugin.name), ); - assert.equal(config.plugins.at(-6).name, 'gev-key-setup'); - // The local MCP route follows every provider and precedes the API fallback. - assert.equal(config.plugins.at(-5).name, 'local-mcp'); + assert.equal(config.plugins.at(-7).name, 'gev-key-setup'); + // Token fallback follows real providers and local MCP before the API 404. + assert.equal(config.plugins.at(-6).name, 'local-mcp'); + assert.equal(config.plugins.at(-5).name, 'google-token-unavailable'); assert.equal(config.plugins.at(-4).name, 'api-not-found'); // Config-only, so it sits after the provider slice rather than in it. assert.equal(config.plugins.at(-3).name, 'gev-unwatched-runtime-dirs');