From f93755af488585bd745f75c7baa43bebbed5dc3e Mon Sep 17 00:00:00 2001 From: Bruno Gomes Date: Tue, 6 Oct 2026 16:13:45 -0300 Subject: [PATCH 1/6] fix(cctv): start live HLS on plain-HTTP LAN pages The client lease id came from crypto.randomUUID, which browsers only expose in secure contexts. Opened over http://:4173 (the HOST=0.0.0.0 opt-in), starting a live camera feed threw before playback. Fall back to a v4 UUID from crypto.getRandomValues, which the server's lease check accepts. --- CHANGELOG.md | 6 ++++ docs/CURRENT-STATE.md | 4 ++- src/layers/cctv/videoPlayback.js | 28 +++++++++++++++++- src/layers/cctv/videoPlayback.test.mjs | 39 +++++++++++++++++++++++++- 4 files changed, 74 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b433f8c06..9fc367d84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,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, diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d4fe022a8..fde1373af 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4814,7 +4814,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 diff --git a/src/layers/cctv/videoPlayback.js b/src/layers/cctv/videoPlayback.js index d59143b5a..48988c17c 100644 --- a/src/layers/cctv/videoPlayback.js +++ b/src/layers/cctv/videoPlayback.js @@ -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}` diff --git a/src/layers/cctv/videoPlayback.test.mjs b/src/layers/cctv/videoPlayback.test.mjs index 1f8c1c0bb..d1ef02539 100644 --- a/src/layers/cctv/videoPlayback.test.mjs +++ b/src/layers/cctv/videoPlayback.test.mjs @@ -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(); From 6cc05a7fcbf89a6a581e8a94cd5b42b87baad8be Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:14:22 -0500 Subject: [PATCH 2/6] fix(ui): recut #918 src/ui/panelRails.test.mjs on current main --- src/ui/panelRails.test.mjs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs index 016de58fa..de5b3997b 100644 --- a/src/ui/panelRails.test.mjs +++ b/src/ui/panelRails.test.mjs @@ -704,6 +704,25 @@ 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 ignores a hidden panel: no lane, no gap, no auto-collapse', () => { const f = fixture('right'); const imagery = element('recent-imagery-panel', { height: 0 }); From 21a90b41442af3a1f7c56d86c95c376a174e92dd Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:14:25 -0500 Subject: [PATCH 3/6] fix(ui): recut #918 src/ui/rightPanelRail.js on current main --- src/ui/rightPanelRail.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ui/rightPanelRail.js b/src/ui/rightPanelRail.js index 3b85bb896..b62be511c 100644 --- a/src/ui/rightPanelRail.js +++ b/src/ui/rightPanelRail.js @@ -72,7 +72,9 @@ export function layoutRightPanelRail({ // A floating (lifted-out) panel is not laid out by the rail. 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 && + getComputedStyle(panel).display !== 'none', ); if (!hud.visible || hud.variant !== 'tactical') { for (const panel of panels.filter((item) => From fa53eec53dd81622e68244d47c29471565e3d349 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:33:25 -0400 Subject: [PATCH 4/6] fix(search): refuse empty coordinate components instead of silently dropping them --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 5 +++++ src/search/coordinate-empty-components.test.mjs | 12 ++++++++++++ src/search/coordinateParser.js | 4 ++-- 4 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 src/search/coordinate-empty-components.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index b24ac95ed..598588ffa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +- Coordinate searches refuse empty latitude or longitude components instead of navigating to an unintended location. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 26d56e66b..b490d308f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5127,3 +5127,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(search): refuse empty coordinate components instead of silently dropping them + +Require exactly two nonempty components while preserving comma, semicolon and whitespace pairs. Malformed separator input falls through to geocoding. diff --git a/src/search/coordinate-empty-components.test.mjs b/src/search/coordinate-empty-components.test.mjs new file mode 100644 index 000000000..48bca54ed --- /dev/null +++ b/src/search/coordinate-empty-components.test.mjs @@ -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], + ); +}); diff --git a/src/search/coordinateParser.js b/src/search/coordinateParser.js index 261e2394c..cb8c85524 100644 --- a/src/search/coordinateParser.js +++ b/src/search/coordinateParser.js @@ -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. */ From 7bbf0aa55af84a6382283eff4640f2ddecde5f2e Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:12:41 -0400 Subject: [PATCH 5/6] fix: address review feedback for PR #1022 --- docs/CURRENT-STATE.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b490d308f..8e4f58af1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4143,6 +4143,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 @@ -5127,8 +5129,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(search): refuse empty coordinate components instead of silently dropping them - -Require exactly two nonempty components while preserving comma, semicolon and whitespace pairs. Malformed separator input falls through to geocoding. From 6f0d1bb35f121ec69b64d136901e60416a339a05 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 11:07:18 -0700 Subject: [PATCH 6/6] fix(ui): keep panels the right rail hides itself in its layout Exclusive presentation hides collapsed right-rail panels with display: none. Skipping every display: none panel also skipped those, so panels the rail had auto-collapsed stayed collapsed after the HUD was hidden or left Tactical. Only panels hidden by other CSS are skipped now. Adds the changelog and current-state notes. --- CHANGELOG.md | 4 ++++ docs/CURRENT-STATE.md | 3 +++ src/ui/panelRails.test.mjs | 26 ++++++++++++++++++++++++++ src/ui/rightPanelRail.js | 9 +++++++-- 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7acdedf6b..7c7ada1b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +- 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. + - 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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c77d1d8e3..59ba91202 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1047,6 +1047,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 diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs index de5b3997b..7015c89e2 100644 --- a/src/ui/panelRails.test.mjs +++ b/src/ui/panelRails.test.mjs @@ -723,6 +723,32 @@ test('right layout ignores a CSS-hidden expanded panel when deciding exclusivity ); }); +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 }); diff --git a/src/ui/rightPanelRail.js b/src/ui/rightPanelRail.js index b62be511c..be49999fd 100644 --- a/src/ui/rightPanelRail.js +++ b/src/ui/rightPanelRail.js @@ -69,12 +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 && - getComputedStyle(panel).display !== 'none', + (hiddenByRail(panel) || getComputedStyle(panel).display !== 'none'), ); if (!hud.visible || hud.variant !== 'tactical') { for (const panel of panels.filter((item) =>