Merge branch 'main' into i18n-spanish

This commit is contained in:
Meir Michanie authored and GitHub committed 2026-10-14 22:01:40 +02:00
commit 29f2885cda
17 files changed
+257 -16

No files matched your search

+1 -1
View File
@@ -150,7 +150,7 @@ jobs:
run: npm run check:boundaries
- name: Run focused onboarding tests
run: node --test src/setupDoctor.test.mjs src/pinokioEnvironment.test.mjs src/pinokioPreflight.test.mjs src/pinokioLauncherContract.test.mjs src/keySetupHardening.test.mjs src/mapStartup.test.mjs src/hudSummaryResponse.test.mjs
run: node --test src/setupDoctor.test.mjs src/pinokioEnvironment.test.mjs src/pinokioPreflight.test.mjs src/pinokioLauncherContract.test.mjs src/keySetupHardening.test.mjs src/mapStartup.test.mjs src/hudSummaryResponse.test.mjs src/codexOauthRealtime.test.mjs
- name: Build production bundle
run: npm run build
+15
View File
@@ -1,5 +1,14 @@
# Changelog
- Snow flakes sway within a fixed range instead of drifting sideways faster the
longer the app runs; the Wind setting still scales the sway.
- The right rail ignores panels that other CSS hides, such as during an event
view, so they no longer force one-panel-at-a-time layout or collapse visible
panels. Panels the rail collapses itself are still restored.
- Coordinate searches refuse empty latitude or longitude components instead of navigating to an unintended location.
- Bing Aerial and Bing Labels request HTTPS tiles on HTTP localhost through
their ion-backed providers, retaining ion and per-tile attribution without
widening the Content Security Policy.
@@ -207,6 +216,12 @@
largest cities) and `CCTV_VEGVESEN_VIDEO=0` keeps stills only. Attributed
under NLOD 2.0.
- Live HLS cameras play on a page opened over plain HTTP from another machine
(`HOST=0.0.0.0`). The client lease id came from `crypto.randomUUID`, which
browsers only expose in secure contexts, so starting a live feed threw
before playback. The id now falls back to a v4 UUID built from
`crypto.getRandomValues`, which the server's lease check accepts.
## [0.2.1] — 2026-10-02 — God's Eye View through Your Agent (with security fixes and other features)
- Ship each bundled data pack once. The region, marine, admin-boundary,
+8
View File
@@ -117,6 +117,14 @@ locked dependencies, finds a free local port, and opens the app.
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
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
away when the same running server was opened in an external browser ([#365](https://github.com/bilawalsidhu/gods-eye-view/issues/365)).
That points to the embedded browser rather than the app server or your
hardware; it does not establish a minimum GPU requirement.
### Path 2 — Terminal / coding agent
Use **Node.js 24.x (24.14.0 or later) or 26.x**. The setup doctor warns about
+1 -1
View File
@@ -158,7 +158,7 @@ routing and regional caches. Request parameters cannot override these endpoints.
Voice controls accept an action runner and a controller factory through
`createVoiceCommands` (`./voice/commands`). The default Realtime controller owns
microphone tracks, playback, push-to-talk, tool cancellation and radio handoff.
Its backend supplies `requestToken({ tier, signal })` and
Its backend supplies `requestToken({ tier, authMode, signal })` and
`negotiate({ offerSdp, credential, signal })`. The Realtime-compatible adapter
(`./voice/realtime-backend`) accepts separate token and connection transports
and endpoints. Only the short-lived client secret reaches the SDP endpoint.
+10 -1
View File
@@ -1013,6 +1013,8 @@ and sharpen. Display supplies actions and renders settings; effect execution has
no DOM dependency. Existing presets and the 500 ms transition remain unchanged.
Stopping the controller revokes animation before asynchronous UI teardown; final
destruction removes its stages and restores the borrowed bloom configuration.
Snow's sideways sway stays within a fixed range at any session age; Wind scales
its amplitude.
## Display control ownership
@@ -1047,6 +1049,9 @@ Automatic collapse remains presentation only, prioritizes the latest explicit
panel, and honors keyboard focus on the right. Mobile layout still releases
desktop allocation styles. Stable Display allocation avoids unnecessary style
writes. This extraction does not change panel positions or layout defaults.
The right rail skips panels hidden by other CSS (computed `display: none`), but
keeps the collapsed panels its own exclusive presentation hides, so automatic
collapse can still be undone.
## Surface keyboard lifecycle
@@ -4347,6 +4352,8 @@ and unreachable upstream (502/504) separately from road geometry.
### Place-search providers
Coordinate input requires exactly two nonempty components; extra comma or semicolon separators decline to geocoding rather than silently choosing a location.
Location search/fly-to, annotations and Radio location lookup receive one
`placeSearch.geocode(query, { bias, signal })` service. `src/standalone` composes
two offline providers first — a strict decimal-degree coordinate reader and an
@@ -5247,7 +5254,9 @@ its paint loop. Camera deactivation destroys the decoder, and fatal playback
errors revert to the labeled frame fallback. Camera changes clear the old panel
frame. hls.js is imported only when an HLS camera starts. Two live sessions may
coexist with at most eight independent consumer leases each; no-access lease
expiry is 15 seconds. Closing one consumer leaves other consumers running. Encrypted/fMP4/byte-range playlists fail
expiry is 15 seconds. Closing one consumer leaves other consumers running. Lease
ids use `crypto.randomUUID` where the page is a secure context and an equivalent
v4 id from `crypto.getRandomValues` on plain-HTTP LAN pages. Encrypted/fMP4/byte-range playlists fail
closed. Performance and live agency reliability require recorded validation.
## Wind forecast models
+6
View File
@@ -128,6 +128,12 @@ Use the same controls before attributing a difference to the application:
6. Record live object counts before attributing a difference to the client.
7. Treat a live-source outage as missing coverage, not as evidence of low client
rendering cost.
8. Record whether the app is running in Pinokio's embedded view or in an
external browser. One Pinokio 8.2.0 user reported severe stutter in the
embedded view that went away when the same local URL was opened in a normal
browser ([#365](https://github.com/bilawalsidhu/gods-eye-view/issues/365)).
Do not compare embedded-view and external-browser measurements directly;
compare the reported GPU renderer and matched scene settings first.
## What is not established yet
+10
View File
@@ -8,6 +8,7 @@ The session adapter, action runner and backend interfaces remain unchanged.
| Owner | State and responsibility |
| --- | --- |
| `realtimeConnection.js` | Start generation, request cancellation, peer/data channel, acquired microphone stream and playback element |
| `realtimeBackend.js` | Token and SDP requests, their separate transports, the abort/timeout scope around both, and client-secret expiry |
| `realtimeTurns.js` | Active and superseded responses, pending confirmations, deduplicated calls and action cancellation |
| `realtimeRadio.js` | Speaker ducking, prepared playback, stronger-action reservations and playback observers |
| `realtimeInput.js` | Physical Space gesture, microphone mute, input/output meters and their frame/listener lifetime |
@@ -21,6 +22,15 @@ The session adapter, action runner and backend interfaces remain unchanged.
| `resultDisplay.js` | What the voice card shows for a result and which items it numbers (read by the card and `referents.js`) |
| `gevActions.js` runner | Analyst follow-up memory (session: `resetConversation()` on stop, `dispose()` on removal) |
The backend is replaceable per deployment: `realtimeController.js` accepts it as a
`backend` option and falls back to `createRealtimeBackend()`. Its two method
signatures live with [application construction](APPLICATION.md); what belongs here
is the ownership. The connection is the backend's only consumer in the application.
The connection reads credential metadata for diagnostics and cost accounting,
then passes the complete credential unchanged to `negotiate`. Client-secret expiry is refused inside the backend, not by the connection.
`realtimeOwners.test.mjs` drives whole sessions through a substitute that
implements only those two methods.
Pure input policy, preferences and protocol-response policy have separate modules.
Owners receive named readers, operations or focused collaborators. Cross-owner
cancellation uses operations; response handling does not own Radio's reservations
+13 -2
View File
@@ -21,9 +21,20 @@ export function assertNode24AllocationRuntime(version = process.versions.node) {
return version;
}
/**
* Directories this runner searches for `*.test.mjs`, in stable order.
*
* `server/` belongs here because provider modules own tests of their own: a
* suite placed beside the module it covers must run, and CI's unit-test job runs
* `npm test`, so a directory missing from this list is a suite that never runs
* there. `unitTestRunner.test.mjs` asserts the list covers every `*.test.mjs`
* outside dependencies, build output and dot-directories, so adding a tree
* without adding it here fails.
*/
export const UNIT_TEST_ROOTS = Object.freeze(['src', 'server']);
/** Discover repository unit tests in stable path order. */
export function discoverUnitTestFiles(root = process.cwd()) {
const sourceRoot = path.join(root, 'src');
const files = [];
const visit = (directory) => {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
@@ -34,7 +45,7 @@ export function discoverUnitTestFiles(root = process.cwd()) {
}
}
};
visit(sourceRoot);
for (const testRoot of UNIT_TEST_ROOTS) visit(path.join(root, testRoot));
return files.sort();
}
+5 -2
View File
@@ -145,17 +145,20 @@ test('Codex OAuth reader enforces expiry margin and bounds tokens without exp by
test('Codex login resolves an installed executable before PATH and starts browser auth detached', async () => {
const checked = [];
// Built with path.join so the stub matches the resolver on Windows too,
// where the separators are backslashes.
const installed = path.join('/home/fixture', '.local', 'bin', 'codex');
assert.equal(
resolveCodexExecutable({
environment: {},
home: '/home/fixture',
access(candidate) {
checked.push(candidate);
if (candidate.endsWith('/.local/bin/codex')) return;
if (candidate === installed) return;
throw new Error('missing');
},
}),
path.join('/home/fixture', '.local', 'bin', 'codex'),
installed,
);
assert.equal(checked.length, 1);
+27 -1
View File
@@ -1,3 +1,28 @@
/**
* A v4 UUID for the client lease. `crypto.randomUUID` only exists in secure
* contexts, so a page opened over plain HTTP from another machine (the
* HOST=0.0.0.0 LAN opt-in) has none; `getRandomValues` exists in every context
* and yields the same shape the server's lease check accepts.
*/
export function createLeaseId(cryptoImpl = globalThis.crypto) {
if (typeof cryptoImpl.randomUUID === 'function') {
return cryptoImpl.randomUUID();
}
const bytes = cryptoImpl.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0'));
return [
hex.slice(0, 4),
hex.slice(4, 6),
hex.slice(6, 8),
hex.slice(8, 10),
hex.slice(10),
]
.map((group) => group.join(''))
.join('-');
}
/** One decoder per active camera; both surfaces consume this video element. */
export function attachCctvVideo(
video,
@@ -7,13 +32,14 @@ export function attachCctvVideo(
loadHls = () => import('hls.js'),
onFailure = () => {},
fetchImpl = globalThis.fetch,
cryptoImpl = globalThis.crypto,
} = {},
) {
// Preserve replay for existing finite video feeds; live HLS must not loop.
video.loop = feedType !== 'hls';
let disposed = false;
let hls = null;
const leaseId = feedType === 'hls' ? globalThis.crypto.randomUUID() : null;
const leaseId = feedType === 'hls' ? createLeaseId(cryptoImpl) : null;
const mediaUrl =
feedType === 'hls'
? `${url}${url.includes('?') ? '&' : '?'}lease=${leaseId}`
+38 -1
View File
@@ -1,6 +1,15 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { attachCctvVideo } from './videoPlayback.js';
import { webcrypto } from 'node:crypto';
import { attachCctvVideo, createLeaseId } from './videoPlayback.js';
const LEASE_V4 =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
/** What an insecure context exposes: getRandomValues but no randomUUID. */
const insecureCrypto = {
getRandomValues: (array) => webcrypto.getRandomValues(array),
};
function video() {
const v = new EventTarget();
Object.assign(v, {
@@ -69,6 +78,34 @@ test('native HLS releases its client lease without response-header access', asyn
assert.equal(releases[0].init.method, 'DELETE');
});
test('lease ids are v4 UUIDs with or without crypto.randomUUID', () => {
assert.match(createLeaseId(), LEASE_V4);
const ids = new Set();
for (let i = 0; i < 64; i++) ids.add(createLeaseId(insecureCrypto));
assert.equal(ids.size, 64);
for (const id of ids) assert.match(id, LEASE_V4);
});
test('HLS starts on a plain-HTTP LAN page without crypto.randomUUID', async () => {
const source = video();
source.canPlayType = () => 'probably';
const playback = attachCctvVideo(source, '/api/cctv/media/a', 'hls', {
loadHls: async () => ({ default: { isSupported: () => false } }),
fetchImpl: async () => {},
cryptoImpl: insecureCrypto,
});
try {
await playback.ready;
const lease = new URL(
source.src,
'http://192.168.1.10:4173/',
).searchParams.get('lease');
assert.match(lease, LEASE_V4);
} finally {
playback.dispose();
}
});
test('finite video feeds retain looping while live HLS does not loop', async () => {
for (const feedType of ['mp4', 'webm', 'hls']) {
const source = video();
@@ -0,0 +1,12 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseCoordinateQuery } from './coordinateParser.js';
test('search refuses omitted components and repeated separators', () => {
for (const q of ['30,, -97', ',30,-97', '30,-97,', '30;; -97', '30,; -97'])
assert.equal(parseCoordinateQuery(q), null, q);
for (const q of ['30,-97', '30; -97', '30 -97', 'N 30, W 97'])
assert.deepEqual(
[parseCoordinateQuery(q).lat, parseCoordinateQuery(q).lon],
[30, -97],
);
});
+2 -2
View File
@@ -49,8 +49,8 @@ function splitComponents(query) {
const parts = /[,;]/.test(text)
? text.split(/[,;]/)
: text.split(/\s+(?=[+-]?[.\d]|[NSEW]\s*[+-]?[.\d])/i);
const trimmed = parts.map((part) => part.trim()).filter(Boolean);
return trimmed.length === 2 ? trimmed : null;
const trimmed = parts.map((part) => part.trim());
return trimmed.length === 2 && trimmed.every(Boolean) ? trimmed : null;
}
/** Present a parsed pair the way a coordinate is normally written. */
+4 -2
View File
@@ -31,11 +31,13 @@ export const snowShader = {
float depth = 0.5 + layer * 0.5;
float speed = 0.4 + layer * 0.3;
float size = mix(0.01, 0.025, layer);
float windForce = sin(time * 0.5 + layer * 3.14) * (0.05 + wind * 0.2);
float windForce = 0.05 + wind * 0.2;
vec2 snowUV = uv * vec2(1.0, 0.5) * (4.0 + layer * 4.0);
snowUV.y += time * speed;
snowUV.x += time * windForce;
// Offset is the integral of a swaying velocity sin(time * 0.5 + phase) * windForce,
// so the sway stays bounded however long the app has been running.
snowUV.x -= 2.0 * cos(time * 0.5 + layer * 3.14) * windForce;
vec2 cell = floor(snowUV);
vec2 f = fract(snowUV);
+45
View File
@@ -704,6 +704,51 @@ test('narrow-screen rails pin every hosted panel glow inside its panel box', ()
}
});
test('right layout ignores a CSS-hidden expanded panel when deciding exclusivity', () => {
const f = fixture('right');
const weather = element('weather-panel', { height: 300 });
weather.computed.display = 'none';
f.stack.children.push(weather);
weather.parentElement = f.stack;
f.run();
assert.equal(f.stack.classList.contains('layout-exclusive'), false);
assert.equal(f.stack.dataset.expandedCount, '0');
assert.equal(f.first.getAttribute('aria-hidden'), undefined);
assert.equal(f.second.getAttribute('aria-hidden'), undefined);
assert.equal(
weather.style.getPropertyValue('--right-panel-allocated-height'),
'',
);
});
test('right layout still restores panels that exclusive presentation hides', () => {
const f = fixture('right');
// Model layers.css: an exclusive rail hides its collapsed panels.
f.options.windowRef.getComputedStyle = (node) => ({
...node.computed,
display:
f.stack.classList.contains('layout-exclusive') &&
node.classList.contains('collapsed')
? 'none'
: node.computed.display,
});
f.expand(f.first, 250);
f.second.classList.add('layout-auto-collapsed');
f.stack.classList.add('layout-exclusive');
f.run();
assert.equal(f.stack.classList.contains('layout-exclusive'), true);
assert.equal(f.second.getAttribute('aria-hidden'), 'true');
f.options.hud.visible = false;
f.run();
assert.equal(f.second.classList.contains('collapsed'), false);
assert.equal(f.second.classList.contains('layout-auto-collapsed'), false);
assert.deepEqual(f.collapsed, ['second']);
});
test('right layout ignores a hidden panel: no lane, no gap, no auto-collapse', () => {
const f = fixture('right');
const imagery = element('recent-imagery-panel', { height: 0 });
+9 -2
View File
@@ -69,10 +69,17 @@ export function layoutRightPanelRail({
// A collapse may schedule one follow-up, which only measures and allocates.
const isCollapseRetry = pendingCollapseRetries.delete(stack);
// A floating (lifted-out) panel is not laid out by the rail.
// A floating (lifted-out) panel is not laid out by the rail. Neither is one
// that other CSS hides, such as an event view. Exclusive presentation hides
// collapsed panels itself; those stay in the rail so they can be restored.
const hiddenByRail = (panel) =>
stack.classList.contains('layout-exclusive') &&
panel.classList.contains('collapsed');
const panels = [...stack.children].filter(
(panel) =>
panel.matches('[data-panel-id]:not(.panel-floating)') && !panel.hidden,
panel.matches('[data-panel-id]:not(.panel-floating)') &&
!panel.hidden &&
(hiddenByRail(panel) || getComputedStyle(panel).display !== 'none'),
);
if (!hud.visible || hud.variant !== 'tactical') {
for (const panel of panels.filter((item) =>
+51 -1
View File
@@ -1,14 +1,40 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { readFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
ALLOCATION_TEST_FILES,
UNIT_TEST_ROOTS,
allocationTestArgs,
assertNode24AllocationRuntime,
buildUnitTestPlan,
discoverUnitTestFiles,
isCalibratedAllocationRuntime,
} from '../scripts/run-unit-tests.mjs';
const repositoryRoot = fileURLToPath(new URL('..', import.meta.url));
/** Every `*.test.mjs` a contributor could add, found without consulting the runner. */
function testFilesOnDisk(root) {
// Build outputs and dependencies are not places a contributor writes a test,
// and walking them would be slow enough to matter. Everything else counts.
const skip = new Set(['node_modules', 'dist', 'build', 'public']);
const found = [];
const visit = (directory) => {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
if (entry.name.startsWith('.') || skip.has(entry.name)) continue;
const absolute = path.join(directory, entry.name);
if (entry.isDirectory()) visit(absolute);
else if (entry.isFile() && entry.name.endsWith('.test.mjs')) {
found.push(path.relative(root, absolute).split(path.sep).join('/'));
}
}
};
visit(root);
return found.sort();
}
test('unit runner serializes only GC-bracketed allocation microbenchmarks', () => {
const ordinary = [
'src/data/manager.test.mjs',
@@ -37,6 +63,30 @@ test('unit runner serializes only GC-bracketed allocation microbenchmarks', () =
);
});
test('the runner discovers every test file in the repository', () => {
// CI's unit-test job runs `npm test`, which runs exactly what this function
// returns. A `*.test.mjs` outside the searched roots therefore never runs
// there, while still reading — in review and to its author — like covered
// code. Compare the two sets rather than their sizes, so the failure names the
// files instead of only disagreeing about a count. The final equality also
// catches the reverse: files the runner finds that this walk skips.
const discovered = discoverUnitTestFiles(repositoryRoot);
const onDisk = testFilesOnDisk(repositoryRoot);
const undiscovered = onDisk.filter((file) => !discovered.includes(file));
assert.deepEqual(
undiscovered,
[],
`test files the runner never executes: ${undiscovered.join(', ')}`,
);
assert.deepEqual(discovered, onDisk);
for (const file of discovered) {
assert.ok(
UNIT_TEST_ROOTS.some((root) => file.startsWith(`${root}/`)),
`${file} should sit under one of ${UNIT_TEST_ROOTS.join(', ')}`,
);
}
});
test('allocation runtime calibration is explicit and pinned to Node 24', () => {
assert.equal(assertNode24AllocationRuntime('24.19.0'), '24.19.0');
assert.throws(