From da02fd8645dbc2604e49bde8b4b174fcc3bcba35 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 26 Oct 2026 15:31:51 -0700 Subject: [PATCH] feat(share): short /share/ links with a compact versioned view format (#1418) * feat(share): add compact versioned share-view codec Add a standalone codec for `#s=` share links: a version byte followed by protobuf-style tag-length-value fields. Settings equal to their default are omitted and refilled from the same table on decode; unknown fields are skipped by wire type, so new fields never break older apps. Views are a place (camera) or a thing (an aircraft, military aircraft, vessel or satellite to follow, with camera offset and last-known fix); an unknown kind falls back to the camera. The decoder bounds its input and rejects malformed payloads with a reason instead of throwing. A converter maps the long form's parsed state to and from the codec state; the long form is unchanged. * fix(share): tighten codec decoding after review Unknown detection mode and allocation names now fall back to their defaults instead of reaching the lenient long-form migration. Future panel values and style-parameter tokens are skipped rather than applied or rejected, and layer options are accepted only under their owner layer. Wrap longitudes before scaling, normalize negative zero, reuse the layer-state tracking ID grammar, and align the format doc with the decoder. * feat(share): let a host share the link its own way ShareLinkManager.buildLink() returns the current share link without copying it, and copyLink() now copies exactly that. The share button passes the link to an optional services.shareLink(url), which resolves to 'shared', 'copied', 'cancelled' or false; 'cancelled' shows no toast and 'shared' shows the new "Link shared" (es, ru, zh-CN). Without the service the button copies as before. * feat(share): open and make short /share/ links The share button now makes `/share/` links with the compact share-view code, and the app opens them at start-up through the same restore pipeline as long links. Once the app next writes the address, the share path is replaced (no new history entry) by the app's own address and the live long-form hash, so a reload follows the live view. A host can pass the code as `initialShareCode` instead. A code that fails to decode restores nothing and shows "This share link can't be opened". The code gains a trailing CRC-8 check byte, so any single mistyped character is rejected instead of opening a different view. Thing views no longer store a camera: it is derived from the entity's last-known fix and the follow offset on a sphere, the entity kind is implied by the id's field, and the followed entity's layer bit is implied; an aircraft followed from the app's own camera is a 42-character code. The `#s=` hash form is gone. While the camera follows an aircraft, military aircraft or satellite, the link is a thing view with the camera's offset and the entity's position. Opening one turns the layer on and follows the entity through the existing shared-follow restore, then swings the camera round to the shared offset; a vessel view selects the vessel once the vessel feed has it. If the entity is not reporting within its window, the view stays at its last-known fix and says so. The panel and style-parameter tables now come from the codec, and a host share hook that returns a plain value or throws still gets a toast. * fix(share): encode style settings against the style's own look Settings a style applies when selected (bloom, sharpen, HUD, detection, celestial ring and the style's shader parameters) now default, per field, to what that style applies in a fresh session, read from the tables the app applies (the first-load baseline, the style presets and the shader defaults). The encoder compares against the view's style and the decoder fills omitted settings from the decoded style, so a view that keeps its style's look costs only its camera and style. The previous defaults did not match a fresh session (sharpen, HUD and detection were off), so every link paid for them, and a style's own shader parameters were always written. A test pins the per-style defaults as frozen v1 meanings. A followed aircraft in Noir is now 44 characters and the real long-link example 39. * docs: changelog for #1418 --- changelog.d/1418-short-share-links.md | 5 + docs/SHARE-LINK-FORMAT.md | 364 +++++++ scripts/format-scope.json | 10 +- scripts/package-boundaries.json | 10 + src/app/controls.js | 9 +- src/data/layerState.js | 2 +- src/i18n/locales/es.json | 5 + src/i18n/locales/ru.json | 5 + src/i18n/locales/zh-CN.json | 5 + src/loadingFeedback.test.mjs | 25 +- src/panelStackLayout.test.mjs | 5 +- src/share/follow.js | 155 +++ src/share/legacyShareState.js | 119 +++ src/share/legacyShareState.test.mjs | 308 ++++++ src/share/shareLinkPath.js | 56 + src/share/shareLinkPath.test.mjs | 45 + src/share/viewCodec.js | 1399 +++++++++++++++++++++++++ src/share/viewCodec.test.mjs | 1307 +++++++++++++++++++++++ src/share/wire.js | 259 +++++ src/share/wire.test.mjs | 188 ++++ src/sharelink.celestial.test.mjs | 13 +- src/sharelink.js | 179 ++-- src/testSupport/shareViewFixtures.mjs | 103 ++ src/ui/applicationShell.js | 48 +- src/ui/shareButton.test.mjs | 160 +++ src/ui/shareFollow.js | 190 ++++ src/ui/shareLinks.test.mjs | 554 ++++++++++ src/ui/shareRestoration.js | 174 ++- 28 files changed, 5605 insertions(+), 97 deletions(-) create mode 100644 changelog.d/1418-short-share-links.md create mode 100644 docs/SHARE-LINK-FORMAT.md create mode 100644 src/share/follow.js create mode 100644 src/share/legacyShareState.js create mode 100644 src/share/legacyShareState.test.mjs create mode 100644 src/share/shareLinkPath.js create mode 100644 src/share/shareLinkPath.test.mjs create mode 100644 src/share/viewCodec.js create mode 100644 src/share/viewCodec.test.mjs create mode 100644 src/share/wire.js create mode 100644 src/share/wire.test.mjs create mode 100644 src/testSupport/shareViewFixtures.mjs create mode 100644 src/ui/shareButton.test.mjs create mode 100644 src/ui/shareFollow.js create mode 100644 src/ui/shareLinks.test.mjs diff --git a/changelog.d/1418-short-share-links.md b/changelog.d/1418-short-share-links.md new file mode 100644 index 000000000..8dce5761f --- /dev/null +++ b/changelog.d/1418-short-share-links.md @@ -0,0 +1,5 @@ +- Share links are now short: the share button makes `/share/` links + that open on any copy of the app, follow the aircraft, military aircraft or + satellite you were following, and fall back to its last-known position with + a notice if it isn't reporting. A mistyped link is refused instead of + opening a different view. Long `#v=2` links keep working (samehkhamis, #1418). diff --git a/docs/SHARE-LINK-FORMAT.md b/docs/SHARE-LINK-FORMAT.md new file mode 100644 index 000000000..f590e02bc --- /dev/null +++ b/docs/SHARE-LINK-FORMAT.md @@ -0,0 +1,364 @@ +# Share-link format + +A share link restores a view: where the camera is, what it follows, the visual +style, data layers and panel layout. There are two forms. + +- **Long form** — `#v=2&lat=30.2672&lon=-97.7431&…`. Parsed by + `src/sharelink.js`. The address bar follows the live view in this form, and + links already in the wild keep working; the long form is never removed. +- **Short form** — `/share/`, where `` is wherever the app is + served (an origin's root, or a base path) and `` is encoded and + decoded by `src/share/viewCodec.js`, which is pure (no DOM, no Cesium) and + needs no server. The share button makes this form. The same code opens on + any copy of the app. `src/share/shareLinkPath.js` reads and builds the + address, and `src/share/legacyShareState.js` converts between the long + form's parsed state and the code's state. + +The same view in both forms (a real session's link): + +| Form | Characters | +| ----- | ----------------- | +| Long | 302 (the hash) | +| Short | 39 (the ``) | + +For example, a camera-only place view is a 24-character code (31 with its +created-at time). +Following an aircraft is 42–50: 42 when the link is made from the app's own +follow camera with the fix taken as the link is made, 44 with a fix four +seconds older, and 50 after orbiting to another heading and pitch. A view in +any other style that keeps the style's own look adds only the style (two +bytes): the 42-character followed aircraft in Noir is 44. + +## Opening a short link + +At start-up the app reads `` from an address whose path ends in +`share/` (a host can pass the code instead, as `initialShareCode` to +`createApplicationControls`). A code that decodes runs the same restore as a +long link; once the app next writes the address, `/share/` is replaced +by the app's own address and the long form, without a new history entry, so +a reload follows the live view. A code that is rejected restores nothing: the +app starts as usual and says the link can't be opened. + +A thing view turns on its entity's layer and follows the entity by id, using +the same restore as a long link's followed flight or satellite (and its +expiry windows); a vessel is selected once the vessel feed has it. Opening is +best effort: until the entity is found, and if it is not found in time, the +camera is where the link was made, looking at the entity's last-known fix, +and the app says that the entity isn't reporting and when the fix was taken. +The share button makes a thing view while the camera follows an aircraft, +military aircraft or satellite. A selected vessel is not followed (the camera +stays where the user puts it), so a link made then is a place view; vessel +thing views open, but the app does not make them yet. + +## Payload + +The code is unpadded base64url (`A–Z a–z 0–9 - _`). The decoded bytes are: + +1. one **version byte**, currently `1`; +2. a sequence of **fields**, each a varint tag `field_number << 3 | wire_type` + followed by its value; +3. one **check byte**: the CRC-8 of everything before it. + +| Wire type | Value | +| --------- | ----------------------------------------------------------- | +| 0 varint | unsigned LEB128, at most 8 bytes, at most 2^53 − 1 | +| 1 fixed64 | 8 bytes (not used by v1; skipped) | +| 2 LEN | varint length, then that many bytes (text or a sub-message) | +| 5 fixed32 | 4 bytes (not used by v1; skipped) | + +Signed values are zigzag varints (0, −1, 1, −2 → 0, 1, 2, 3). Text is printable +ASCII. Fields are written in ascending order; the decoder accepts any order. + +A field whose value equals its default is **omitted**. The decoder fills every +omitted field from the same defaults table the encoder reads (for a +style-dependent field, the decoded style's defaults), so an empty +payload (code `AQc`: the version byte and its check byte) is the all-default +view. + +### Check byte + +The check byte is CRC-8 with polynomial x⁸ + x² + x + 1 (`0x07`), initial +value 0, no reflection and no final XOR (CRC-8/SMBUS; the check value of the +ASCII digits `123456789` is `0xF4`). A decoder that computes a different value +rejects the code (`checksum`) before reading any field, so a mistyped code +never opens a different view. A degree-8 CRC catches every error burst of up +to 8 bits; one base64url character is 6 bits, so **every single-character +typo is rejected** (a test tries every substitution at every position). Two +swapped neighbours, a dropped or an extra character are caught with a +probability of about 255 in 256 (many of them already fail as base64url). + +## Field registry (top level) + +| # | State key | Type | Default | Encoding | +| --- | ---------------------------- | ----- | ----------- | ------------------------------------------------------- | +| 1 | `view.kind` | enum | see below | 0 place, 1 thing | +| 2 | `lat` | coord | 0 | zigzag of degrees × 10⁴ | +| 3 | `lon` | coord | 0 | zigzag of degrees × 10⁴ | +| 4 | `alt` | sint | 800 | whole metres | +| 5 | `heading` | angle | 0 | whole degrees, see angles | +| 6 | `pitch` | sint | −35 | whole degrees | +| 7 | `roll` | angle | 0 | whole degrees | +| 8 | `sharedAtMs` | epoch | none | zigzag seconds since 2026-01-01T00:00:00Z | +| 9 | booleans | flags | 0 | bit set when the value differs from default | +| 10 | `layerState.enabledLayerIds` | LEN | none | layer bitset, see layers | +| 11 | `layerState.options` | LEN | none | packed option triples, see layers | +| 12 | `panelState` | LEN | none | packed panel entries, see panels | +| 13 | thing | LEN | none | sub-message, see thing views | +| 14 | `style` | enum | `normal` | normal, retro, surveillance, thermal, anime, noir, snow | +| 15 | `mapStack` | enum | `photoreal` | photoreal, bing-aerial, bing-labels, esri-imagery, osm | +| 16 | `hudVariant` | enum | style | tactical, operator, minimal, cyber | +| 17 | `bloomIntensity` | uint | style | | +| 18 | `bloomVersion` | uint | 2 | | +| 19 | `sharpenIntensity` | uint | style | | +| 20 | `detectionMode` | enum | style | OFF, SPARSE, BALANCED, DENSE | +| 21 | `detectionDensity` | uint | style | one of 0, 25, 50, 75, 100 | +| 22 | `detectionAllocation` | enum | style | ELASTIC, WEIGHTED | +| 23 | `detectionFadePct` | uint | style | 0–40 | +| 24 | `detectionOutsideOpacityPct` | uint | style | 0–100 | +| 25 | `scopeFeatherPct` | uint | 100 | 0–200 | +| 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus | +| 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs | + +Field 1 absent means `thing` when field 13 is present and `place` otherwise, +so v1 never writes it; a later view kind writes its code. + +Enum codes are the list position (0, 1, 2…). A name not in the list is written +as LEN text in the same field, so the app can add a style or map before this +table learns its code. A new style's settings are encoded against its own +defaults, while a decoder that does not know the style uses Normal's; adding a +style also extends the pinned style-defaults table. + +Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3 +`celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on). + +### Style defaults + +A default marked **style** is what the view's style applies when it is +selected in a fresh session (`SHARE_VIEW_STYLE_DEFAULTS`): the app's +first-load baseline (`GLOBAL_POST_DEFAULTS` in `src/ui/visualPresets.js`), +with the style's preset on top (`STYLE_PRESET_DEFAULTS`), and the style's +shader parameters at the preset's values, else the shader's own. The encoder +compares against the defaults of the view's style, and the decoder fills +omitted settings from the defaults of the style it decoded (an unknown style +uses Normal's). So a view that keeps its style's look costs its camera and +style alone. (A recipient-side preference the app remembers, such as the +detection allocation, is not a style default: a sender who changed it pays +for it.) In v1: + +| Setting | Every style | +| ------------------------- | ------------- | +| bloom, intensity | off, 0 | +| sharpen, intensity | on, 49 | +| HUD visible, variant | on, tactical | +| detection mode, density | DENSE, 75 | +| allocation, fade, outside | ELASTIC, 7, 1 | +| celestial ring | off | + +| Style | Shader parameters (field 27 defaults) | +| ------------ | --------------------------------------------------------------- | +| normal | none | +| retro | pixelation 1, distortion 0, instability 0.42 | +| surveillance | gain 0.18, bloom 0.22, scanlineStr 0.96, pixelation 1 | +| thermal | sensitivity 0.85, bloom 0.2, mode 0.33, pixelation 1, palette 0 | +| anime | saturation 1, edgeThick 0.5 | +| noir | contrastAmt 1.2, grainAmt 0.5, vignetteAmt 0.5 | +| snow | density 0.6, wind 0.5 | + +These are frozen like every other default: the codec reads them from the +app's tables today, and a test pins them, so if a table moves the old values +are frozen in the codec rather than changing what shared links mean. + +### Precision + +| Value | Stored as | Matches the long form | +| --------------------------- | ------------------------- | --------------------- | +| Latitude, longitude | 10⁻⁴ degree (about 11 m) | `toFixed(4)` | +| Altitude, range | whole metres | `Math.round` | +| Heading, pitch, roll | whole degrees | `Math.round` | +| Intensities and percentages | integers | `Math.round` | +| Style parameters | hundredths after clamping | `sp` (× 100) | +| Created-at and observed-at | whole seconds | `at` | + +Angles: heading and roll are normalized into [0, 360) — the long form can +write `roll=360` for a camera at 359.6°, which the compact form stores as 0 — +and written as the nearer signed turn, so 350° costs one byte (−10). +When a state is encoded, out-of-range input is clamped (latitude, pitch) or +wrapped (longitude). A payload that stores a latitude or longitude beyond +±90/±180 cannot come from the encoder and is rejected. + +### Layers (fields 10 and 11) + +A layer's number is derived from its permanent layer-state token +(`src/data/layerStateTokenReservations.json`): a one-character token is its +index in `0–9a–z`, a two-character token is 36 + first × 36 + second. The +token ledger never reassigns a token, so layer numbers never change and new +layers need no change here. + +Field 10 is a bitset: bit _n_ of byte ⌊n/8⌋ (least significant first) is set +when layer _n_ is on. Present-but-empty means "every layer off"; an absent +field leaves the recipient's layers alone (the long form's `l=` versus no `l`). +A thing view's entity layer is always on, so its bit is not written: in a +thing view, present-but-empty means "only the entity's layer", and an absent +field opens with only the entity's layer on (the app writes the field +whenever it has a layer set). + +Field 11 repeats `(layer number, option key, value)` for each option that +differs from its frozen absent meaning (`SHARE_VIEW_LAYER_OPTION_ABSENT`). The +option key is `option token index × 2 + kind`: kind 0 means the value is a +zigzag varint (an option whose long-form spelling is a canonical integer), +kind 1 means a varint length and ASCII text (the long-form spelling). Values +are decoded by the layer-state codec's own option decoders. + +A followed aircraft, military aircraft or satellite is a thing view in the +compact form, not a tracking option: the encoder moves it out of the layer +options, and the converter moves it back for the long form. + +### Panels (field 12) + +Each entry is one varint: `panel number × 16 + collapsed × 4 + pinned`, where +collapsed and pinned are 0 (no value), 1 (false) or 2 (true). Only panels that +differ from the frozen default layout are written, plus a collapsed-0 entry +for each panel the state has no opinion about (an older long-form link that +predates the panel); a panel with no entry takes its default. Values of 3 are +reserved: a decoder ignores that entry, as for collapsed 0. + +| # | Panel | Long-form token | Default | +| --- | ---------------------- | --------------- | ------------------- | +| 0 | `control-panel` | `c` | collapsed, unpinned | +| 1 | `location-bar` | `l` | collapsed, unpinned | +| 2 | `data-panel` | `d` | collapsed | +| 3 | `cctv-panel` | `v` | collapsed | +| 4 | `recent-imagery-panel` | `i` | expanded | +| 5 | `street-level-panel` | `t` | collapsed | +| 6 | `radio-panel` | `r` | collapsed | +| 7 | `scene-panel` | `s` | collapsed | +| 8 | `global-context-panel` | `g` | collapsed | +| 9 | `pp-toggles` | `p` | collapsed | +| 10 | `param-slider-panel` | `m` | expanded | + +### Style parameters (field 27) + +Pairs of varints `(token index, value × 100)` for the parameters of the +view's style that differ from its defaults, using the long form's `sp` tokens +(`SHARE_VIEW_STYLE_PARAMS`). The decoded state always lists every parameter +of the style: the stored ones over the style's defaults. A long link without +`sp` converts to the style's defaults. + +## View kinds + +A **place** view (field 13 absent) opens at its camera, fields 2–7. + +A **thing** view (field 13 present) follows an entity. Field 13 is a +sub-message: + +| # | Field | Encoding | Default | +| --- | ----------- | -------------------------------------------------------- | ------- | +| 1 | aircraft | entity id: varint (24-bit ICAO address) or LEN text | | +| 2 | military | entity id: varint (24-bit ICAO address) or LEN text | | +| 3 | vessel | entity id: varint (MMSI) or LEN text | | +| 4 | satellite | entity id: varint (NORAD catalog number) or LEN text | | +| 5 | range | uint metres from the entity to the camera | 2000 | +| 6 | heading | angle, the camera's heading while following | 0 | +| 7 | pitch | sint whole degrees, the camera's pitch while following | −35 | +| 8 | fix lat | coord, the entity's last-known latitude | none | +| 9 | fix lon | coord, its last-known longitude | none | +| 10 | fix alt | sint metres | 0 | +| 11 | fix age | sint seconds the fix predates the created-at time | 0 | +| 12 | observed-at | epoch, when the fix was taken; only without a created-at | none | + +The entity kind is the field that carries the id (its code in +`SHARE_VIEW_ENTITY_KINDS`), so it costs nothing; exactly one of fields 1–4 is +present (two is malformed). Entity ids are the ones the layers look up: an +aircraft's ICAO 24-bit address (six hex digits, stored as a number; other +forms such as TIS-B `~abc123` as text), a vessel's MMSI and a satellite's +NORAD catalog number (decimal numbers; an MMSI with a leading zero stays +text). Ids are lowercase and match `[0-9a-z~_-]{1,16}`. + +Fields 5–7 are the follow offset: the camera looks along (heading, pitch) at +the entity from `range` metres away, heading clockwise from north, in the +entity's local east-north-up frame. The defaults match the app's own follow +camera, which looks north at about −35°, so only its range is written. + +Fields 8–12 are the last-known fix, the entity's position when the link was +made. Fields 8 and 9 are both written whenever there is a fix; their presence +marks it. Its time is the created-at time (field 8 at the top level) minus +field 11, or field 12 when the view has no created-at time. + +**The camera of a thing view with a fix is derived, not stored**: fields 2–7 +are not written, and the camera is where following the fix from the offset +puts it (`followCameraFor` in `src/share/follow.js`): the camera sits +`−r·cos p·sin h`, `−r·cos p·cos h` and `−r·sin p` metres east, north and up +of the fix (heading h, pitch p, range r), on a sphere of radius 6 371 000 m, +and looks back at the fix (its heading and pitch are those of that direction +at the camera, roll 0; at follow distances they equal h and p). A +decoder ignores fields 2–7 when the fix is present. A thing view without a +fix (one converted from a long link, which records neither offset nor fix) +writes its camera in fields 2–7 like a place view. + +A decoder that meets a view kind or an entity kind it does not know (an id in +a thing field it skips) opens the view as a place at its camera — the derived +one when there is a fix — and reports `fallback`. + +## Decoding rules + +`decodeShareView(code)` takes the bare code and never throws. It returns +`{ ok: true, state, unknownFields?, fallback? }` or `{ ok: false, reason }`. + +Rejected (the whole link): input over 2048 characters, bad base64url +(padding, foreign characters, impossible length, stray bits), fewer than two +bytes, a check byte that does not match (`checksum`), an unknown version byte, +a truncated value, a varint over 8 bytes or above 2⁵³ − 1, an +overlong varint, a LEN longer than what remains (at any level, including +inside the thing sub-message), a reserved wire type, field number 0, a known +field with the wrong wire type or repeated, two entity ids in one thing, a +duplicate panel/option/style entry, non-ASCII text, and a latitude or +longitude (camera or fix) beyond ±90/±180. + +Tolerated and reported in `unknownFields`: unknown fields at any level +(skipped by wire type), unknown enum codes and names (the default is used), +unknown flag bits, layers, panel values and style parameters, and layer +options the layer-state codec rejects or that are filed under a layer other +than their owner. Detection mode and allocation names outside their lists +fall back to the default rather than reaching the long form's lenient +migration. The map stack and HUD variant are open: a name outside their lists +that matches `[A-Za-z0-9_-]{1,32}` is kept as written (the app decides what +it means) and is not reported; only a name outside that grammar is. A +created-at time in the future is dropped, as the long form's `at` is. + +## Changing the format + +These rules keep every link that was ever shared working, in every version of +the app that can open it. + +1. **Field numbers are permanent.** Never renumber, never reuse. A field that + is no longer written stays in `SHARE_VIEW_FIELDS` marked `retired: true`. +2. **A field's type and wire type are permanent.** To change one, add a new + field. +3. **Defaults are frozen meanings.** A default is what an omitted field meant + when links were written. If the app's default moves, leave the table alone: + the encoder will start writing the new value explicitly. Style defaults + are read from the app's style tables; when those move, freeze the old + values in the codec (see Style defaults). The same applies + to `SHARE_VIEW_LAYER_OPTION_ABSENT` and the panel defaults. +4. **Codes are append-only.** Enum lists, entity kinds, flag bits, panel + numbers and style parameter tokens only grow. +5. **Add, don't change.** A new setting takes the next free field number + (28 onwards; fields 1–15 have one-byte tags and are all taken). A new + boolean takes the next flag bit instead, which costs nothing when it is at + its default. +6. **New view kinds and entity kinds** get the next code. A new entity kind's + id takes the next free thing field (13 onwards), so older apps skip it and + fall back to the camera. A thing that needs more than an id (for example a + CCTV camera, a radio station or a transit vehicle) adds further thing + fields the same way. +7. **A new layer option** needs an entry in `SHARE_VIEW_LAYER_OPTION_ABSENT` + (a test fails until it has one). +8. **Bump the version byte only** for a change older decoders must refuse. + Decoders reject any version they do not know. The trailing CRC-8 check + byte belongs to every version. +9. **Derivations are frozen too.** `followCameraFor` and the follow-offset + defaults are part of the format: changing them moves the camera of every + thing link already shared. + +Add a test for every change: a round trip, and a payload written with the new +field that the previous decoder still reads. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 84d2dc323..ee23b8b6a 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1109,5 +1109,13 @@ "src/voice/panel/inactivity.test.mjs", "src/voice/panel/actionObserver.test.mjs", "src/voice/panel/testDom.mjs", - "src/voice/realtimeCostPanel.test.mjs" + "src/voice/realtimeCostPanel.test.mjs", + "docs/SHARE-LINK-FORMAT.md", + "src/share/wire.test.mjs", + "src/share/viewCodec.test.mjs", + "src/share/legacyShareState.test.mjs", + "src/testSupport/shareViewFixtures.mjs", + "src/ui/shareLinks.test.mjs", + "src/ui/shareButton.test.mjs", + "src/share/shareLinkPath.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 4b4f3f18f..dedf4c272 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -770,6 +770,9 @@ "src/ui/scenePresentation.js", "src/ui/actionButton.js", "src/ui/scenes.js", + "src/ui/shareFollow.js", + "src/share/follow.js", + "src/data/trackedCamera.js", "src/ui/shareRestoration.js", "src/ui/shellElements.js", "src/ui/shellFeedback.js", @@ -2195,6 +2198,11 @@ "src/services/meshFloorSampler.js", "src/services/requests.js", "src/services/terrainHeights.js", + "src/share/follow.js", + "src/share/legacyShareState.js", + "src/share/shareLinkPath.js", + "src/share/viewCodec.js", + "src/share/wire.js", "src/sharelink.js", "src/sources/live/aircraft.js", "src/sources/live/contract.js", @@ -2287,6 +2295,7 @@ "src/ui/scenePresentation.js", "src/ui/actionButton.js", "src/ui/scenes.js", + "src/ui/shareFollow.js", "src/ui/shareRestoration.js", "src/ui/shellElements.js", "src/ui/shellFeedback.js", @@ -3283,6 +3292,7 @@ "src/data/layerStateTokenReservations.json", "src/loadingFeedback.js", "src/navigationPolicy.js", + "src/share/follow.js", "src/ui/shareRestoration.js", "src/ui/uiLifetime.js", "src/i18n/index.js", diff --git a/src/app/controls.js b/src/app/controls.js index 40b354d06..0b479f43d 100644 --- a/src/app/controls.js +++ b/src/app/controls.js @@ -4,7 +4,11 @@ import { flyToAustin } from '../camera.js'; import { initCockpitCloudEffects } from '../cockpitCloudEffects.js'; import { t } from '../i18n/index.js'; -/** Construct the existing controls and camera presentation. */ +/** + * Construct the existing controls and camera presentation. `initialShareCode` + * opens a share code (the `` of `/share/`) at start-up, for a host + * whose address does not carry one. + */ export function createApplicationControls({ scene: { viewer, mapStackController, operations }, loaderStatus, @@ -13,6 +17,7 @@ export function createApplicationControls({ catalog, placeSearch, defer, + initialShareCode = null, }) { // Initialize the style manager (post-processing, HUD, locations, share links) const styleManager = new Controls(viewer, { @@ -27,6 +32,8 @@ export function createApplicationControls({ requestServices: operations.requests, mapStackController, placeSearch, + // A share code to open at start-up instead of the page's address. + initialShareCode, }); defer(() => styleManager.orbitController.stop()); defer(() => styleManager.hud.destroy()); diff --git a/src/data/layerState.js b/src/data/layerState.js index f43129896..089c8c08b 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -23,7 +23,7 @@ const NEW_SINGLE_CHARACTER_TOKEN_PATTERN = /^[0-9]$/; * out-of-grammar ID is rejected outright, because half an address is a * DIFFERENT aircraft, not a shorter name for the same one. */ -const TRACKING_ID_GRAMMAR = /^[0-9a-z~_-]{1,16}$/; +export const TRACKING_ID_GRAMMAR = /^[0-9a-z~_-]{1,16}$/; /** * Ceilings for the untrusted v2 layer fields. The layer ceiling covers the * complete reserved one-character space plus every two-character base-36 diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 3ff8e57a5..2cfa47a69 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -302,6 +302,11 @@ "CLOUD UNKNOWN": "NUBES DESCONOCIDAS", "Panel layout updated — positions reset to new defaults": "Disposición de paneles actualizada — posiciones restablecidas a los nuevos valores predeterminados", "Link copied!": "¡Enlace copiado!", + "Link shared": "Enlace compartido", + "This share link can't be opened": "Este enlace compartido no se puede abrir", + "{id} isn't reporting now — showing where it was at {time}": "{id} no está transmitiendo ahora; se muestra dónde estaba a las {time}", + "{id} isn't reporting now — showing where it was last seen": "{id} no está transmitiendo ahora; se muestra dónde se vio por última vez", + "Shared vessel is unavailable": "El buque compartido no está disponible", "Copy failed": "No se pudo copiar", "Location not found": "Ubicación no encontrada", "Search failed": "No se pudo completar la búsqueda", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 3c7ff17b1..b3bdd165f 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -294,6 +294,11 @@ "Scene run complete": "Воспроизведение сцены завершено", "CCTV layer unavailable": "Слой CCTV недоступен", "Link copied!": "Ссылка скопирована!", + "Link shared": "Ссылка отправлена", + "This share link can't be opened": "Не удаётся открыть эту ссылку", + "{id} isn't reporting now — showing where it was at {time}": "{id} сейчас не передаёт данные — показано последнее известное место ({time})", + "{id} isn't reporting now — showing where it was last seen": "{id} сейчас не передаёт данные — показано последнее известное место", + "Shared vessel is unavailable": "Судно по ссылке недоступно", "Copy failed": "Не удалось скопировать", "VISUAL PRESETS": "ПРЕСЕТЫ", "LOCATION": "МЕСТО", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index e3eefe7c5..a44973167 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1477,6 +1477,11 @@ "LIVE SIGNALS": "实时信号", "CCTV layer unavailable": "CCTV 图层不可用", "Link copied!": "链接已复制!", + "Link shared": "链接已分享", + "This share link can't be opened": "无法打开此分享链接", + "{id} isn't reporting now — showing where it was at {time}": "{id} 目前没有发送信号——显示其在 {time} 的位置", + "{id} isn't reporting now — showing where it was last seen": "{id} 目前没有发送信号——显示其最后出现的位置", + "Shared vessel is unavailable": "分享的船只不可用", "Copy failed": "复制失败", "Return map to straight-down view": "将地图恢复为垂直俯视", "Tilt map to oblique view": "将地图倾斜至斜视角", diff --git a/src/loadingFeedback.test.mjs b/src/loadingFeedback.test.mjs index b9903f97e..ff9f63c9c 100644 --- a/src/loadingFeedback.test.mjs +++ b/src/loadingFeedback.test.mjs @@ -94,20 +94,27 @@ test('deferred terminal notices lose ownership to newer acquisition epochs and d test('share-follow failures use the universal top-center status instead of the bottom toast', () => { const handler = shellMethod('_handleShareTrackingRestoreStatus').toString(); - assert.match(handler, /this\.showStatus\(message\)/); - assert.match(handler, /this\.initialRestorePromise\.then\(showAfterStartupCover\)/); - assert.match(handler, /this\._lifetime\.frame\(\(\) => \{/); - assert.match(handler, /this\._lifetime\.listen\(\s*startupCover,\s*'transitionend',\s*showOnce,\s*\{ once: true \},?\s*\)/); - assert.match(handler, /fallbackTimer = this\._lifetime\.timeout\(showOnce, 1000\)/); - assert.doesNotMatch(handler, /this\._showToast\(message\)/); - assert.doesNotMatch(handler, /pushCockpitSignal/); + const present = shellMethod('_presentAfterStartupCover').toString(); + assert.match( + handler, + /this\.initialRestorePromise\.then\(\(\) =>\s*this\._presentAfterStartupCover\(message, noticeGeneration\),?\s*\)/, + ); + assert.match(present, /this\.showStatus\(message\)/); + assert.match(present, /this\._lifetime\.frame\(\(\) => \{/); + assert.match(present, /this\._lifetime\.listen\(\s*startupCover,\s*'transitionend',\s*showOnce,\s*\{ once: true \},?\s*\)/); + assert.match(present, /fallbackTimer = this\._lifetime\.timeout\(showOnce, 1000\)/); + assert.match(present, /canPresentDeferredStatusNotice\(/); + for (const source of [handler, present]) { + assert.doesNotMatch(source, /this\._showToast\(message\)/); + assert.doesNotMatch(source, /pushCockpitSignal/); + } assert.match(handler, /result\.classification === 'pending'/); assert.match(handler, /state: 'acquiring'/); assert.match(handler, /persistent: true/); assert.match(handler, /this\._shareTrackingNoticeGeneration \+= 1/); - assert.match(handler, /canPresentDeferredStatusNotice\(/); assert.match(handler, /if \(this\._shareTrackingAcquiringKey\) return/); - assert.match(handler, /result\.classification === 'followed'\s*\|\|\s*result\.classification === 'cancelled'/); + assert.match(handler, /result\.classification === 'followed'/); + assert.match(handler, /result\.classification === 'cancelled'\) return/); }); test('universal notice masks active loading only for its own fixed dwell', () => { diff --git a/src/panelStackLayout.test.mjs b/src/panelStackLayout.test.mjs index 2a472e246..a13f5c04b 100644 --- a/src/panelStackLayout.test.mjs +++ b/src/panelStackLayout.test.mjs @@ -193,7 +193,8 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c test('share-panel state excludes responsive collapse and preserves recipient preferences', () => { const ui = readShellSource(); - const sharelink = readFileSync(new URL('./sharelink.js', import.meta.url), 'utf8'); + // The long form spells the share format's panel table (one source of truth). + const panels = readFileSync(new URL('./share/viewCodec.js', import.meta.url), 'utf8'); assert.match( ui, @@ -211,7 +212,7 @@ test('share-panel state excludes responsive collapse and preserves recipient pre 'pin and unpin must update the share hash even when collapse state is unchanged', ); assert.match(ui, /\{ id: 'param-slider-panel' \}/); - assert.match(sharelink, /\{ id: 'param-slider-panel', token: 'm', pinnable: false \}/); + assert.match(panels, /id: 'param-slider-panel',\s*token: 'm',\s*pinnable: false,/); }); test('parameterized Display presets keep one stable scroll owner', () => { diff --git a/src/share/follow.js b/src/share/follow.js new file mode 100644 index 000000000..e24fc003b --- /dev/null +++ b/src/share/follow.js @@ -0,0 +1,155 @@ +/** + * What a share link's thing view follows, and where its camera is: the + * entity kinds and the follow-offset geometry of the share format + * (docs/SHARE-LINK-FORMAT.md). Like the rest of the format, every code, + * default and formula here is permanent. + * + * Pure and dependency-free, so the app's follow camera and the codec + * (viewCodec.js) share one definition. + */ + +const freezeAll = (rows) => + Object.freeze(rows.map((row) => Object.freeze(row))); + +/** + * Things a view can follow. `code` is permanent and is also the number of the + * thing-message field that carries this kind's id, so the kind costs nothing + * on the wire. `idForm` picks the compact numeric spelling (a 24-bit ICAO + * address or a decimal number); any other identifier travels as text. + * `tracking` names the layer-state slot that holds the same selection in the + * long form. + */ +export const SHARE_VIEW_ENTITY_KINDS = freezeAll([ + { + code: 1, + kind: 'aircraft', + layerId: 'flights', + idForm: 'hex24', + tracking: 'flights', + }, + { + code: 2, + kind: 'military_aircraft', + layerId: 'military', + idForm: 'hex24', + tracking: 'military', + }, + { + code: 3, + kind: 'vessel', + layerId: 'ais-live-vessels', + idForm: 'decimal', + tracking: null, + }, + { + code: 4, + kind: 'satellite', + layerId: 'satellites', + idForm: 'decimal', + tracking: 'satellites', + }, +]); +/** + * The frozen meaning of an omitted follow-offset field. An offset whose + * fields all equal these is written as nothing, so it decodes as these. + */ +export const SHARE_VIEW_FOLLOW_OFFSET_DEFAULT = Object.freeze({ + range: 2000, + heading: 0, + pitch: -35, +}); + +const EARTH_RADIUS_M = 6_371_000; +const RAD = Math.PI / 180; + +/** + * The camera's offset from a followed entity in the entity's local east, + * north and up metres: the camera looks along (heading, pitch) at the entity + * from `range` metres away. Heading is clockwise from north; a negative pitch + * looks down, so the camera sits above the entity. + */ +export function followOffsetToEnu({ range, heading, pitch }) { + const horizontal = range * Math.cos(pitch * RAD); + return { + east: -horizontal * Math.sin(heading * RAD), + north: -horizontal * Math.cos(heading * RAD), + up: -range * Math.sin(pitch * RAD), + }; +} + +/** The inverse of {@link followOffsetToEnu}, in whole metres and degrees. */ +export function followOffsetFromEnu({ east, north, up }) { + const range = Math.hypot(east, north, up); + if (!(range > 0)) return null; + return { + range: Math.round(range), + heading: (Math.round(Math.atan2(-east, -north) / RAD) + 360) % 360, + pitch: Math.round(Math.asin(Math.max(-1, Math.min(1, -up / range))) / RAD), + }; +} + +/** Earth-centred position of (lat, lon, alt) on the format's sphere. */ +function toEcef(lat, lon, alt) { + const r = EARTH_RADIUS_M + alt; + const cosLat = Math.cos(lat * RAD); + return [ + r * cosLat * Math.cos(lon * RAD), + r * cosLat * Math.sin(lon * RAD), + r * Math.sin(lat * RAD), + ]; +} + +/** The local east, north and up unit vectors at (lat, lon). */ +function enuBasis(lat, lon) { + const sinLat = Math.sin(lat * RAD); + const cosLat = Math.cos(lat * RAD); + const sinLon = Math.sin(lon * RAD); + const cosLon = Math.cos(lon * RAD); + return { + east: [-sinLon, cosLon, 0], + north: [-sinLat * cosLon, -sinLat * sinLon, cosLat], + up: [cosLat * cosLon, cosLat * sinLon, sinLat], + }; +} + +const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + +/** + * Where the camera sits while following an entity at `fix` from `offset`, + * and how it looks back at the entity, on a sphere of radius 6 371 000 m. A + * thing view with a fix stores no camera of its own: this is its camera, and + * the place a decoder falls back to. Pure, and computed only from values at + * the format's precision, so encoder and decoder agree. + */ +export function followCameraFor(fix, offset) { + const local = followOffsetToEnu(offset); + const at = toEcef(fix.lat, fix.lon, fix.alt); + const basis = enuBasis(fix.lat, fix.lon); + const camera = [0, 1, 2].map( + (i) => + at[i] + + local.east * basis.east[i] + + local.north * basis.north[i] + + local.up * basis.up[i], + ); + const horizontal = Math.hypot(camera[0], camera[1]); + const lat = Math.atan2(camera[2], horizontal) / RAD; + const lon = Math.atan2(camera[1], camera[0]) / RAD; + const alt = Math.hypot(horizontal, camera[2]) - EARTH_RADIUS_M; + // The view direction, camera to entity, in the camera's own local frame. + const view = [0, 1, 2].map((i) => at[i] - camera[i]); + const own = enuBasis(lat, lon); + const length = Math.hypot(...view); + if (!(length > 0)) + return { ...fix, heading: offset.heading, pitch: offset.pitch, roll: 0 }; + return { + lat, + lon, + alt, + heading: + (Math.atan2(dot(view, own.east), dot(view, own.north)) / RAD + 360) % 360, + pitch: + Math.asin(Math.max(-1, Math.min(1, dot(view, own.up) / length))) / RAD, + roll: 0, + }; +} diff --git a/src/share/legacyShareState.js b/src/share/legacyShareState.js new file mode 100644 index 000000000..1f54445e4 --- /dev/null +++ b/src/share/legacyShareState.js @@ -0,0 +1,119 @@ +/** + * Conversion between the long share-link form's parsed state (what + * `ShareLinkManager.parseInitialHash()` returns for `#v=2&lat=…`) and the + * compact share-view state of viewCodec.js. + * + * The two use the same field names, so the conversion is small: the compact + * form adds `view`, and the long form keeps a followed entity as a tracking + * selection inside the layer options instead. + */ + +import { + SHARE_TRACKING_RESTORE_POLICIES, + createDefaultLayerState, + normalizeLayerState, +} from '../data/layerState.js'; +import { + SHARE_VIEW_ENTITY_KINDS, + normalizeShareViewState, +} from './viewCodec.js'; + +/** Fields of the long form's parsed state, in its own order. */ +export const LEGACY_SHARE_STATE_KEYS = Object.freeze([ + 'lat', + 'lon', + 'alt', + 'heading', + 'pitch', + 'roll', + 'style', + 'styleParams', + 'bloom', + 'sharpen', + 'bloomIntensity', + 'bloomVersion', + 'sharpenIntensity', + 'hudVariant', + 'hudVisible', + 'detectionMode', + 'detectionDensity', + 'detectionAllocation', + 'detectionFadePct', + 'detectionOutsideOpacityPct', + 'celestialRing', + 'scopeEnabled', + 'scopeFeatherPct', + 'scopeTerminusPct', + 'mapStack', + 'layerState', + 'layerStateInvalid', + 'panelState', + 'sharedAtMs', +]); + +/** + * A compact share-view state from a parsed long-form state. A tracking + * selection in the layer options becomes a thing view with no offset or + * last-known fix, since the long form records neither. Restore bookkeeping + * (`restoreAuthority`, `layerStateInvalid`) is not part of a view. + */ +export function fromLegacyShareState(parsed) { + if (!parsed || typeof parsed !== 'object') return null; + const picked = {}; + for (const key of LEGACY_SHARE_STATE_KEYS) { + if (key !== 'layerStateInvalid' && Object.hasOwn(parsed, key)) + picked[key] = parsed[key]; + } + return normalizeShareViewState(picked); +} + +/** + * The parsed long-form state for a compact share-view state, ready for + * `ShareLinkManager.applyState`. A thing view's layer is on (in a fresh layer + * set when the view has none), and a followed aircraft, military aircraft or + * satellite goes back into its layer's tracking selection. The long form + * cannot express the rest of a thing view (a vessel, the follow offset, the + * last-known fix); callers that need them read `state.view` directly. + */ +export function toLegacyShareState(input) { + const state = normalizeShareViewState(input); + const entry = + state.view.kind === 'thing' + ? SHARE_VIEW_ENTITY_KINDS.find((e) => e.kind === state.view.entity.kind) + : null; + let layerState = + entry && !state.layerState + ? normalizeLayerState({ + ...createDefaultLayerState(), + enabledLayerIds: [entry.layerId], + }) + : state.layerState; + const policy = entry?.tracking + ? SHARE_TRACKING_RESTORE_POLICIES[entry.tracking] + : null; + if ( + layerState && + policy && + layerState.enabledLayerIds.includes(entry.layerId) + ) { + const id = + entry.kind === 'satellite' + ? Number(state.view.entity.id) + : state.view.entity.id; + layerState = normalizeLayerState({ + ...layerState, + options: { + ...layerState.options, + [policy.optionOwner]: { + ...layerState.options[policy.optionOwner], + [policy.optionKey]: id, + }, + }, + }); + } + const legacy = {}; + for (const key of LEGACY_SHARE_STATE_KEYS) legacy[key] = state[key]; + legacy.layerState = layerState; + legacy.layerStateInvalid = false; + return legacy; +} diff --git a/src/share/legacyShareState.test.mjs b/src/share/legacyShareState.test.mjs new file mode 100644 index 000000000..cde3a9ed6 --- /dev/null +++ b/src/share/legacyShareState.test.mjs @@ -0,0 +1,308 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { ShareLinkManager } from '../sharelink.js'; +import { createDefaultLayerState } from '../data/layerState.js'; +import { HUD_LAYOUTS } from '../hudLayoutPolicy.js'; +import { VIEW_MAPS, VIEW_STYLES } from '../view/index.js'; +import { + HAL_EXAMPLE_HASH, + pick, + randomLayerState, + rng, +} from '../testSupport/shareViewFixtures.mjs'; +import { + SHARE_VIEW_PANELS, + SHARE_VIEW_STYLE_PARAMS, + decodeShareView, + encodeShareView, +} from './viewCodec.js'; +import { SHARE_VIEW_STYLE_DEFAULTS } from './viewCodec.js'; +import { + LEGACY_SHARE_STATE_KEYS, + fromLegacyShareState, + toLegacyShareState, +} from './legacyShareState.js'; + +const DEG = Math.PI / 180; + +function stubViewer(camera = {}) { + return { + camera: { + changed: { addEventListener: () => () => {} }, + positionCartographic: camera.position ?? { + latitude: 0, + longitude: 0, + height: 0, + }, + heading: camera.heading ?? 0, + pitch: camera.pitch ?? 0, + roll: camera.roll ?? 0, + }, + }; +} + +/** The long form's own parser, run on `hash`. */ +function parseLegacy(hash) { + const body = hash.startsWith('#') ? hash : `#${hash}`; + globalThis.window = { + location: { hash: body, href: `http://localhost/${body}` }, + }; + const manager = new ShareLinkManager(stubViewer()); + const parsed = manager.parseInitialHash(); + manager.destroy(); + return parsed; +} + +/** + * The parsed state as a view: restore bookkeeping removed, and headings and + * rolls wrapped into [0, 360) — the codec's one normalization of values the + * long-form generator writes (Cesium can round 359.6° up to `roll=360`). + */ +function comparable(parsed) { + const out = {}; + for (const key of LEGACY_SHARE_STATE_KEYS) out[key] = parsed[key]; + // A long link without `sp` leaves the style's parameters as they are; the + // compact form says what they are, the style's own unless changed. + const ownParams = SHARE_VIEW_STYLE_DEFAULTS[out.style].styleParams; + out.styleParams = ownParams ? { ...ownParams, ...out.styleParams } : null; + for (const key of ['heading', 'roll']) + out[key] = ((out[key] % 360) + 360) % 360; + return out; +} + +/** Legacy hash → parsed state → codec → parsed state, asserting equality. */ +function assertLegacyRoundTrip(hash) { + const parsed = parseLegacy(hash); + assert.ok(parsed, `long form parses: ${hash}`); + const state = fromLegacyShareState(parsed); + const encoded = encodeShareView(state); + const decoded = decodeShareView(encoded, { nowMs: Date.now() }); + assert.equal(decoded.ok, true, decoded.reason); + assert.deepEqual(decoded.state, state, 'codec state survives'); + assert.deepEqual( + toLegacyShareState(decoded.state), + comparable(parsed), + 'parsed long-form state survives', + ); + return { parsed, state, encoded }; +} + +test('the HAL example survives the round trip and shrinks to 39 characters', () => { + const { parsed, state, encoded } = assertLegacyRoundTrip(HAL_EXAMPLE_HASH); + assert.equal(HAL_EXAMPLE_HASH.length, 302); + assert.equal(encoded.length, 39); + assert.equal(encoded, 'ARCg-SQYrah3ILAJKB4wO1IEAIQAEGIBNMgBC1o'); + assert.equal(parsed.roll, 360); + assert.equal(state.roll, 0); + assert.deepEqual(state.layerState.enabledLayerIds, [ + 'ais-live-vessels', + 'flights', + 'satellites', + ]); + assert.equal(state.layerState.options.flights.models3d, true); + assert.equal(state.layerState.options.wind.overlay, 'none'); + assert.deepEqual( + state.panelState.specs.find((spec) => spec.id === 'cctv-panel'), + { id: 'cctv-panel', collapsed: false, pinned: null }, + ); +}); + +test('links from earlier long-form versions keep their historical meaning', () => { + for (const hash of [ + 'lat=10&lon=20', + 'lat=37.7749&lon=-122.4194&alt=800&heading=0&pitch=-35&style=nvg&bloom=1&bi=84&sharpen=0&si=65&hud=tactical&hv=1&dm=BALANCED&dd=50&da=elastic&kf=16&ko=0&cr=0&map=photoreal', + 'v=2&lat=1&lon=2&l=f&lo=f.m.a', + 'v=2&lat=1&lon=2&l=', + 'v=2&lat=1&lon=2&l=k.r&lo=r.f.g-jazz_r.v.35', + 'v=2&lat=1&lon=2&ui=v.c.0', + 'v=2&lat=1&lon=2&style=crt&sp=p.500_d.25_i.100', + 'v=2&lat=1&lon=2&sce=0&scf=999&dm=GOD&dd=10&at=1767225600', + ]) { + assertLegacyRoundTrip(hash); + } + // A pre-v2 link's omitted settings are written explicitly, so the compact + // form keeps the fallbacks the long form gives them (ko=5, kf=16, scf=35). + const old = fromLegacyShareState(parseLegacy('lat=10&lon=20')); + assert.deepEqual( + [old.detectionOutsideOpacityPct, old.detectionFadePct, old.scopeFeatherPct], + [5, 16, 35], + ); + assert.deepEqual( + [old.bloomIntensity, old.bloomVersion], + [50, 1], + 'legacy bloom scale', + ); + // `l=f` without `e` is a v2 link with 3D models OFF; the codec writes that. + const off = fromLegacyShareState(parseLegacy('v=2&lat=1&lon=2&l=f')); + assert.equal(off.layerState.options.flights.models3d, false); +}); + +test('a tracking selection becomes a thing view and back', () => { + const flights = assertLegacyRoundTrip('v=2&lat=1&lon=2&l=f&lo=f.t.a1b2c3'); + assert.deepEqual(flights.state.view.entity, { + kind: 'aircraft', + id: 'a1b2c3', + }); + assert.equal( + flights.state.layerState.options.flights.selectedFlightsTrackingId, + null, + ); + const military = assertLegacyRoundTrip('v=2&lat=1&lon=2&l=m&lo=f.u.~ae1234'); + assert.deepEqual(military.state.view.entity, { + kind: 'military_aircraft', + id: '~ae1234', + }); + const satellite = assertLegacyRoundTrip('v=2&lat=1&lon=2&l=s&lo=s.t.25544'); + assert.deepEqual(satellite.state.view.entity, { + kind: 'satellite', + id: '25544', + }); + assert.equal( + toLegacyShareState(satellite.state).layerState.options.satellites + .selectedSatTrackingId, + 25544, + ); +}); + +test('thing views the long form cannot express convert without one', () => { + const layerState = createDefaultLayerState(); + layerState.enabledLayerIds = ['ais-live-vessels']; + const vessel = toLegacyShareState({ + lat: 1, + lon: 2, + layerState, + view: { kind: 'thing', entity: { kind: 'vessel', id: '366999712' } }, + }); + assert.deepEqual(vessel.layerState, layerState); +}); + +test('a thing view turns its layer on, with or without a layer set', () => { + for (const layerState of [createDefaultLayerState(), null]) { + const legacy = toLegacyShareState({ + layerState, + view: { kind: 'thing', entity: { kind: 'aircraft', id: 'a1b2c3' } }, + }); + assert.deepEqual(legacy.layerState.enabledLayerIds, ['flights']); + assert.equal( + legacy.layerState.options.flights.selectedFlightsTrackingId, + 'a1b2c3', + ); + assert.equal(legacy.layerStateInvalid, false); + } + const vessel = toLegacyShareState({ + view: { kind: 'thing', entity: { kind: 'vessel', id: '366999712' } }, + }); + assert.deepEqual(vessel.layerState.enabledLayerIds, ['ais-live-vessels']); + // A place view without a layer set leaves the recipient's layers alone. + assert.equal(toLegacyShareState({ lat: 1, lon: 2 }).layerState, null); +}); + +/** A long-form link written by the real generator from random live state. */ +function generatedLegacyHash(random) { + const int = (min, max) => min + Math.floor(random() * (max - min + 1)); + globalThis.window = { location: { hash: '', href: 'http://localhost/' } }; + const manager = new ShareLinkManager( + stubViewer({ + position: { + latitude: (int(-899999, 899999) / 1e4) * DEG, + longitude: (int(-1799999, 1799999) / 1e4) * DEG, + height: pick(random, [int(-50, 3000), int(0, 30_000_000)]), + }, + heading: int(0, 359) * DEG + (random() < 0.1 ? 0.99 * DEG : 0), + pitch: int(-90, 0) * DEG, + roll: pick(random, [0, 359.7 * DEG, int(0, 359) * DEG]), + }), + ); + const style = pick(random, VIEW_STYLES); + manager.onStyleChange(style); + const density = pick(random, [0, 25, 50, 75, 100]); + manager.onToggleChange(random() < 0.5, random() < 0.5, { + bloomIntensity: int(0, 100), + bloomVersion: pick(random, [1, 2]), + sharpenIntensity: int(0, 100), + hudVariant: pick(random, HUD_LAYOUTS), + hudVisible: random() < 0.5, + detectionMode: pick(random, ['OFF', 'SPARSE', 'BALANCED', 'DENSE']), + detectionDensity: density, + detectionAllocation: pick(random, ['ELASTIC', 'WEIGHTED']), + detectionFadePct: int(0, 40), + detectionOutsideOpacityPct: int(0, 100), + celestialRingEnabled: random() < 0.5, + scopeEnabled: random() < 0.5, + scopeFeatherPct: int(0, 200), + scopeTerminusPct: random() < 0.3 ? int(94, 100) : null, + mapStack: pick(random, VIEW_MAPS), + }); + if (random() < 0.8) { + const layerState = randomLayerState(random); + manager.setLayerStateProvider(() => layerState); + } + if (random() < 0.8) { + const panelState = { + specs: SHARE_VIEW_PANELS.filter(() => random() < 0.9).map((panel) => ({ + id: panel.id, + collapsed: random() < 0.7, + ...(panel.pinnable ? { pinned: random() < 0.3 } : {}), + })), + }; + manager.setPanelStateProvider(() => panelState); + } + const specs = SHARE_VIEW_STYLE_PARAMS[style]; + if (specs && random() < 0.7) { + const values = Object.fromEntries( + specs.map((spec) => [ + spec.key, + spec.min + (spec.max - spec.min) * random(), + ]), + ); + manager.setStyleParamStateProvider(() => values); + } + const params = manager._buildHashParams(); + if (random() < 0.7) + params.set('at', String(int(1_600_000_000, Math.floor(Date.now() / 1000)))); + manager.destroy(); + return params.toString(); +} + +test('links written by the long-form generator survive the compact form', () => { + const random = rng(0xc0dec); + let totalLegacy = 0; + let totalCompact = 0; + for (let i = 0; i < 400; i += 1) { + const hash = generatedLegacyHash(random); + const { encoded } = assertLegacyRoundTrip(hash); + totalLegacy += hash.length; + totalCompact += encoded.length; + } + // Even random states, far from defaults, are under half the length. + assert.ok( + totalCompact * 2 < totalLegacy, + `${totalCompact} vs ${totalLegacy}`, + ); +}); + +test('the converter ignores non-objects and restore bookkeeping', () => { + // A malformed v2 layer list is reported by the long-form parser; the view + // itself simply has no layer set. + const invalid = parseLegacy('v=2&lat=1&lon=2&l=f.unknown'); + assert.equal(invalid.layerStateInvalid, true); + assert.equal(fromLegacyShareState(invalid).layerState, null); + assert.equal(fromLegacyShareState(null), null); + const parsed = parseLegacy(HAL_EXAMPLE_HASH); + assert.ok(parsed.restoreAuthority); + const state = fromLegacyShareState(parsed); + assert.equal(Object.hasOwn(state, 'restoreAuthority'), false); + assert.equal(Object.hasOwn(state, 'layerStateInvalid'), false); +}); + +test('the converter knows every key of the long form’s parsed state', () => { + // A key the converter does not list would silently drop out of share codes. + globalThis.window = { location: { hash: '', href: 'http://localhost/' } }; + const manager = new ShareLinkManager(stubViewer({})); + const parsed = manager._parseShareHash('v=2&lat=1&lon=2'); + assert.deepEqual( + Object.keys(parsed).sort(), + [...LEGACY_SHARE_STATE_KEYS].sort(), + ); + manager.destroy(); +}); diff --git a/src/share/shareLinkPath.js b/src/share/shareLinkPath.js new file mode 100644 index 000000000..ee52a235f --- /dev/null +++ b/src/share/shareLinkPath.js @@ -0,0 +1,56 @@ +/** + * Share-link addresses: a share code travels in the path, `/share/`, + * where `` is wherever the app is served (the origin's root, or a base + * path). The same code opens on any copy of the app. + * + * Pure: no DOM. Callers pass the addresses they have. + */ + +/** The path segment that introduces a share code. */ +export const SHARE_PATH_SEGMENT = 'share'; + +// Any last segment counts as a code here, so a mangled one is refused by the +// decoder (and said so) rather than opening the app at a wrong address. +const SHARE_PATH = new RegExp(`^(.*/)${SHARE_PATH_SEGMENT}/([^/]+)/?$`); + +/** + * The share code and the app's base path for a share-link pathname, or null + * when the pathname is not a share link. `/share/AQc` gives + * `{ code: 'AQc', basePath: '/' }`; `/gev/share/AQc` gives basePath `/gev/`. + */ +export function parseSharePath(pathname) { + if (typeof pathname !== 'string') return null; + const match = SHARE_PATH.exec(pathname); + if (!match) return null; + let code = match[2]; + try { + code = decodeURIComponent(code); + } catch { + // Left as written; the decoder refuses it. + } + return { code, basePath: match[1] }; +} + +/** + * The app's root address for `href`: the base path of a share link, or the + * directory of any other page, with no hash. The query is kept. + */ +export function appRootUrl(href) { + const url = new URL(href); + url.hash = ''; + const share = parseSharePath(url.pathname); + url.pathname = share ? share.basePath : new URL('./', url).pathname; + return url; +} + +/** + * The share link for `code` on the app at `href`. The sender's `?lang` stays + * behind: the recipient keeps their own language. + */ +export function shareLinkUrl(code, href) { + const root = appRootUrl(href); + const url = new URL(`${SHARE_PATH_SEGMENT}/${code}`, root); + url.search = root.search; + url.searchParams.delete('lang'); + return url.href; +} diff --git a/src/share/shareLinkPath.test.mjs b/src/share/shareLinkPath.test.mjs new file mode 100644 index 000000000..aab06741d --- /dev/null +++ b/src/share/shareLinkPath.test.mjs @@ -0,0 +1,45 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { appRootUrl, parseSharePath, shareLinkUrl } from './shareLinkPath.js'; + +test('a share path names its code and the app base path', () => { + assert.deepEqual(parseSharePath('/share/AQc'), { + code: 'AQc', + basePath: '/', + }); + assert.deepEqual(parseSharePath('/gev/share/AQc/'), { + code: 'AQc', + basePath: '/gev/', + }); + // A mangled code is still a share link, so the app refuses it out loud. + assert.equal(parseSharePath('/share/AQ.c').code, 'AQ.c'); + for (const path of ['/', '/share', '/share/', '/share/a/b', '/sharex/AQc']) + assert.equal(parseSharePath(path), null, path); + assert.equal(parseSharePath(undefined), null); +}); + +test('the app root drops a share path and the hash, and keeps the query', () => { + assert.equal( + appRootUrl('https://gev.example/share/AQc?qa=1#v=2').href, + 'https://gev.example/?qa=1', + ); + assert.equal( + appRootUrl('http://localhost:4173/gev/share/AQc').href, + 'http://localhost:4173/gev/', + ); + assert.equal( + appRootUrl('https://gev.example/index.html#v=2&lat=1').href, + 'https://gev.example/', + ); +}); + +test('a share link sits under the app root without the sender ?lang', () => { + assert.equal( + shareLinkUrl('AQc', 'https://gev.example/?lang=ru#v=2&lat=1'), + 'https://gev.example/share/AQc', + ); + assert.equal( + shareLinkUrl('AQc', 'https://gev.example/gev/share/old?qa=1&lang=es'), + 'https://gev.example/gev/share/AQc?qa=1', + ); +}); diff --git a/src/share/viewCodec.js b/src/share/viewCodec.js new file mode 100644 index 000000000..84ba8fbc1 --- /dev/null +++ b/src/share/viewCodec.js @@ -0,0 +1,1399 @@ +/** + * Compact share-view codec, format version 1. + * + * A shared view travels as a short code in a link's path, `/share/`. The + * code is base64url text for one version byte, protobuf-style tag-length-value + * fields and a one-byte CRC-8 check. Settings equal to their default are + * omitted, and the decoder fills them back in from the same defaults table the + * encoder reads. Older decoders skip fields they do not know, so new fields + * never break old links or old apps. + * + * The rules that keep links valid forever (field numbers, codes and defaults + * are permanent) are in docs/SHARE-LINK-FORMAT.md. Read them before changing + * anything in this file. + * + * Pure: no DOM, no Cesium. The long `#v=2&lat=…` form is still parsed by + * sharelink.js; legacyShareState.js converts between the two. + */ + +import { + LAYER_STATE_REGISTRY, + LAYER_STATE_TOKEN_ALPHABET, + REGISTRY_BY_ID, + SHARE_TRACKING_RESTORE_POLICIES, + TRACKING_ID_GRAMMAR, + normalizeLayerState, + optionSpecs, +} from '../data/layerState.js'; +import { + migrateDetectionState, + normalizeAllocationStrategy, +} from '../data/detectionPolicy.js'; +import { clampScopeFeatherPct } from '../scopeMaskPolicy.js'; +import { VIEW_STYLES } from '../view/index.js'; +import { + GLOBAL_POST_DEFAULTS, + STYLE_PRESET_DEFAULTS, + STYLES, +} from '../ui/visualPresets.js'; +import { + SHARE_VIEW_ENTITY_KINDS, + SHARE_VIEW_FOLLOW_OFFSET_DEFAULT, + followCameraFor, +} from './follow.js'; +import { + MAX_ZIGZAG_MAGNITUDE, + ShareWireError, + WIRE_LEN, + WIRE_VARINT, + WireReader, + WireWriter, + asciiText, + decodeBase64Url, + asciiBytes, + crc8, + encodeBase64Url, + unzigzag, + zigzag, +} from './wire.js'; + +export { + SHARE_VIEW_ENTITY_KINDS, + SHARE_VIEW_FOLLOW_OFFSET_DEFAULT, + followCameraFor, + followOffsetFromEnu, + followOffsetToEnu, +} from './follow.js'; + +/** First payload byte. Bump only for a change older decoders must refuse. */ +export const SHARE_VIEW_FORMAT_VERSION = 1; +/** Input bound, checked before any decoding work (1536 payload bytes). */ +export const MAX_SHARE_VIEW_CHARS = 2048; + +/** Created-at and observed-at seconds are stored relative to this instant. */ +export const SHARE_VIEW_EPOCH_S = Date.UTC(2026, 0, 1) / 1000; + +/** Coordinates are stored as integer multiples of this many degrees (≈11 m). */ +const COORDINATE_SCALE = 1e4; +const MAX_ALTITUDE_M = 1e9; +const MAX_FOLLOW_RANGE_M = 1e8; +const MAX_UNSIGNED_SETTING = 1e6; +/** Terminus pin range; mirrors SCOPE_TERMINUS_MIN/MAX_PCT in scopeMask.js. */ +const SCOPE_TERMINUS_MIN_PCT = 94; +const SCOPE_TERMINUS_MAX_PCT = 100; +const NAME_GRAMMAR = /^[A-Za-z0-9_-]{1,32}$/; +const MAX_OPTION_VALUE_CHARS = 256; + +const freezeAll = (rows) => + Object.freeze(rows.map((row) => Object.freeze(row))); + +/** + * Enum code tables. A code is permanent: append new names, never renumber or + * reuse. A name missing from its table still travels, as text in the same + * field (wire type LEN), so the app can grow faster than this table. + */ +export const SHARE_VIEW_ENUMS = Object.freeze({ + viewKind: Object.freeze(['place', 'thing']), + style: Object.freeze([ + 'normal', + 'retro', + 'surveillance', + 'thermal', + 'anime', + 'noir', + 'snow', + ]), + mapStack: Object.freeze([ + 'photoreal', + 'bing-aerial', + 'bing-labels', + 'esri-imagery', + 'osm', + ]), + hudVariant: Object.freeze(['tactical', 'operator', 'minimal', 'cyber']), + detectionMode: Object.freeze(['OFF', 'SPARSE', 'BALANCED', 'DENSE']), + detectionAllocation: Object.freeze(['ELASTIC', 'WEIGHTED']), +}); + +const ENTITY_BY_KIND = new Map(SHARE_VIEW_ENTITY_KINDS.map((e) => [e.kind, e])); +const ENTITY_BY_CODE = new Map(SHARE_VIEW_ENTITY_KINDS.map((e) => [e.code, e])); + +/** + * Shareable panels, numbered permanently. `collapsed` and `pinned` are the + * frozen meaning of an omitted entry (the layout a fresh session shows today), + * not a live default: if the app's default moves, this table does not. + * `token` is the panel's letter in the long form's `ui` parameter. + */ +export const SHARE_VIEW_PANELS = freezeAll([ + { + number: 0, + id: 'control-panel', + token: 'c', + pinnable: true, + collapsed: true, + }, + { + number: 1, + id: 'location-bar', + token: 'l', + pinnable: true, + collapsed: true, + }, + { number: 2, id: 'data-panel', token: 'd', pinnable: false, collapsed: true }, + { number: 3, id: 'cctv-panel', token: 'v', pinnable: false, collapsed: true }, + { + number: 4, + id: 'recent-imagery-panel', + token: 'i', + pinnable: false, + collapsed: false, + }, + { + number: 5, + id: 'street-level-panel', + token: 't', + pinnable: false, + collapsed: true, + }, + { + number: 6, + id: 'radio-panel', + token: 'r', + pinnable: false, + collapsed: true, + }, + { + number: 7, + id: 'scene-panel', + token: 's', + pinnable: false, + collapsed: true, + }, + { + number: 8, + id: 'global-context-panel', + token: 'g', + pinnable: false, + collapsed: true, + }, + { number: 9, id: 'pp-toggles', token: 'p', pinnable: false, collapsed: true }, + { + number: 10, + id: 'param-slider-panel', + token: 'm', + pinnable: false, + collapsed: false, + }, +]); +const PANEL_BY_NUMBER = new Map(SHARE_VIEW_PANELS.map((p) => [p.number, p])); +const PANEL_BY_ID = new Map(SHARE_VIEW_PANELS.map((p) => [p.id, p])); + +/** + * Shareable visual-preset parameters per style, as in the long form's `sp` + * parameter: values are stored ×100 after clamping to [min, max]. A + * parameter's token is permanent within its style. + */ +export const SHARE_VIEW_STYLE_PARAMS = Object.freeze({ + retro: freezeAll([ + { key: 'pixelation', token: 'p', min: 1, max: 10 }, + { key: 'distortion', token: 'd', min: 0, max: 1 }, + { key: 'instability', token: 'i', min: 0, max: 1 }, + ]), + surveillance: freezeAll([ + { key: 'gain', token: 'g', min: 0, max: 1 }, + { key: 'bloom', token: 'b', min: 0, max: 1 }, + { key: 'scanlineStr', token: 's', min: 0, max: 1 }, + { key: 'pixelation', token: 'p', min: 1, max: 6 }, + ]), + thermal: freezeAll([ + { key: 'sensitivity', token: 's', min: 0, max: 1 }, + { key: 'bloom', token: 'b', min: 0, max: 1 }, + { key: 'mode', token: 'm', min: 0, max: 1 }, + { key: 'pixelation', token: 'p', min: 1, max: 6 }, + { key: 'palette', token: 'a', min: 0, max: 1 }, + ]), + anime: freezeAll([ + { key: 'saturation', token: 's', min: 0, max: 2 }, + { key: 'edgeThick', token: 'e', min: 0, max: 1 }, + ]), + noir: freezeAll([ + { key: 'contrastAmt', token: 'c', min: 0, max: 2 }, + { key: 'grainAmt', token: 'g', min: 0, max: 1 }, + { key: 'vignetteAmt', token: 'v', min: 0, max: 1 }, + ]), + snow: freezeAll([ + { key: 'density', token: 'd', min: 0, max: 1 }, + { key: 'wind', token: 'w', min: 0, max: 1 }, + ]), +}); + +/** + * The default of a setting a style applies when it is selected: the value + * comes from SHARE_VIEW_STYLE_DEFAULTS for the view's style, so a view that + * keeps its style's look costs nothing for it. + */ +export const BY_STYLE = 'by-style'; + +/** + * Booleans share one varint (field 9). Bit `bit` is set when the value + * DIFFERS from `default`, so an absent field means "all defaults" and a bit + * an older decoder does not know is harmless. Bits are permanent. + */ +export const SHARE_VIEW_FLAGS = freezeAll([ + { bit: 0, key: 'bloom', default: BY_STYLE }, + { bit: 1, key: 'sharpen', default: BY_STYLE }, + { bit: 2, key: 'hudVisible', default: BY_STYLE }, + { bit: 3, key: 'celestialRing', default: BY_STYLE }, + { bit: 4, key: 'scopeEnabled', default: true }, +]); + +/** + * What an omitted layer option means, per option owner and key. Frozen like + * every other default here: it is today's app default, and it stays even if + * that default moves (the long form learned this with `models3d`). A new + * option needs an entry before it ships; until it has one it is always + * written explicitly. + */ +export const SHARE_VIEW_LAYER_OPTION_ABSENT = Object.freeze({ + traffic: Object.freeze({ roadMode: null }), + 'weather-lightning': Object.freeze({ opacity: 'strong' }), + 'weather-radar': Object.freeze({ opacity: 'strong' }), + 'weather-satellite': Object.freeze({ + infrared: 100, + opacity: 'strong', + product: 'clouds-regional', + }), + wind: Object.freeze({ + model: 'gfs', + overlay: 'none', + units: 'km/h', + paused: false, + }), + flights: Object.freeze({ + models3d: true, + models3dMode: 'proximity', + selectedFlightsTrackingId: null, + selectedMilitaryTrackingId: null, + }), + satellites: Object.freeze({ catalog: 'core', selectedSatTrackingId: null }), + cctv: Object.freeze({ + coverageMode: 'on', + showProjection: true, + autoHop: false, + }), + 'recent-imagery': Object.freeze({ + west: null, + south: null, + east: null, + north: null, + a: null, + b: null, + mode: 0, + split: 50, + viirs: false, + }), + 'street-level': Object.freeze({ + mapillary: true, + providerSwitches: '', + pano: 'all', + sinceDays: 0, + }), + radio: Object.freeze({ filter: 'all', volume: 0.8 }), +}); + +/** The frozen meaning of an omitted option; unlisted options are always written. */ +function layerOptionAbsentValue(ownerId, spec) { + const owner = SHARE_VIEW_LAYER_OPTION_ABSENT[ownerId]; + return owner && Object.hasOwn(owner, spec.key) ? owner[spec.key] : undefined; +} + +/** + * The top-level field registry. Numbers are permanent and never reused; a + * retired field stays listed with `retired: true`. Defaults are the frozen + * meaning of an omitted field. Types: + * uint unsigned varint sint zigzag varint + * angle whole degrees in [0, 360), stored as zigzag of (-180, 180] + * coord zigzag varint of degrees × 1e4 + * enum varint code from SHARE_VIEW_ENUMS, or LEN text for other names + * epoch zigzag varint of seconds since SHARE_VIEW_EPOCH_S + * flags varint bitset, see SHARE_VIEW_FLAGS + * bytes LEN, structure documented per field + */ +export const SHARE_VIEW_FIELDS = freezeAll([ + { number: 1, key: 'view', type: 'enum', enum: 'viewKind', default: 'place' }, + { number: 2, key: 'lat', type: 'coord', default: 0 }, + { number: 3, key: 'lon', type: 'coord', default: 0 }, + { number: 4, key: 'alt', type: 'sint', default: 800 }, + { number: 5, key: 'heading', type: 'angle', default: 0 }, + { number: 6, key: 'pitch', type: 'sint', default: -35 }, + { number: 7, key: 'roll', type: 'angle', default: 0 }, + { number: 8, key: 'sharedAtMs', type: 'epoch', default: null }, + { number: 9, key: 'flags', type: 'flags', default: 0 }, + { number: 10, key: 'layerState', type: 'bytes', default: null }, + { number: 11, key: 'layerOptions', type: 'bytes', default: null }, + { number: 12, key: 'panelState', type: 'bytes', default: null }, + { number: 13, key: 'thing', type: 'bytes', default: null }, + { number: 14, key: 'style', type: 'enum', enum: 'style', default: 'normal' }, + { + number: 15, + key: 'mapStack', + type: 'enum', + enum: 'mapStack', + default: 'photoreal', + }, + { + number: 16, + key: 'hudVariant', + type: 'enum', + enum: 'hudVariant', + default: BY_STYLE, + }, + { number: 17, key: 'bloomIntensity', type: 'uint', default: BY_STYLE }, + { number: 18, key: 'bloomVersion', type: 'uint', default: 2 }, + { number: 19, key: 'sharpenIntensity', type: 'uint', default: BY_STYLE }, + { + number: 20, + key: 'detectionMode', + type: 'enum', + enum: 'detectionMode', + default: BY_STYLE, + }, + { number: 21, key: 'detectionDensity', type: 'uint', default: BY_STYLE }, + { + number: 22, + key: 'detectionAllocation', + type: 'enum', + enum: 'detectionAllocation', + default: BY_STYLE, + }, + { number: 23, key: 'detectionFadePct', type: 'uint', default: BY_STYLE }, + { + number: 24, + key: 'detectionOutsideOpacityPct', + type: 'uint', + default: BY_STYLE, + }, + { number: 25, key: 'scopeFeatherPct', type: 'uint', default: 100 }, + { number: 26, key: 'scopeTerminusPct', type: 'uint', default: null }, + { number: 27, key: 'styleParams', type: 'bytes', default: BY_STYLE }, +]); +const FIELD_BY_KEY = new Map(SHARE_VIEW_FIELDS.map((f) => [f.key, f])); +const fieldNumber = (key) => FIELD_BY_KEY.get(key).number; + +/** + * Fields inside the thing message (top-level field 13). Permanent. Fields 1–4 + * are the entity ids, one per SHARE_VIEW_ENTITY_KINDS code; exactly one is + * present. The follow offset and the last-known fix are flat fields here, not + * sub-messages, to save their tag and length bytes. + */ +export const SHARE_VIEW_THING_FIELDS = Object.freeze({ + range: 5, // uint metres from the entity to the camera + heading: 6, // angle, the camera's heading while following + pitch: 7, // sint whole degrees, the camera's pitch while following + lat: 8, // coord, the entity's last-known latitude + lon: 9, // coord, its last-known longitude + alt: 10, // sint metres, its last-known altitude + ageS: 11, // sint, seconds the fix predates the created-at time (field 8) + observedAt: 12, // epoch, when the fix was taken; only without a created-at +}); + +/** The default of a field or flag for a view in `style`. */ +function defaultOf(entry, style) { + if (entry.default !== BY_STYLE) return entry.default; + const defaults = + SHARE_VIEW_STYLE_DEFAULTS[style] ?? SHARE_VIEW_STYLE_DEFAULTS.normal; + return defaults[entry.key]; +} + +/** + * The complete default state for a view in `style` (Normal when omitted): + * what a payload with only that style decodes to. + */ +export function createDefaultShareViewState(requested = 'normal') { + const style = VIEW_STYLES.includes(requested) ? requested : 'normal'; + const state = { view: { kind: 'place' } }; + for (const field of SHARE_VIEW_FIELDS) { + if (field.key === 'view' || field.key === 'flags') continue; + if (field.key === 'thing' || field.key === 'layerOptions') continue; + state[field.key] = + field.key === 'style' ? style : structuredClone(defaultOf(field, style)); + } + for (const flag of SHARE_VIEW_FLAGS) state[flag.key] = defaultOf(flag, style); + return state; +} + +/* ------------------------------------------------------------------ */ +/* Normalization: the one canonical state both directions agree on. */ +/* ------------------------------------------------------------------ */ + +const finite = (value) => + typeof value === 'number' + ? Number.isFinite(value) + : typeof value === 'string' && + value.trim() !== '' && + Number.isFinite(Number(value)); +const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); + +// `+ 0` turns -0 into 0, which is what the decoder produces. +function intOr(value, fallback, min, max) { + return finite(value) + ? clamp(Math.round(Number(value)), min, max) + 0 + : fallback; +} + +function wrapAngle(value, fallback = 0) { + if (!finite(value)) return fallback; + return ((Math.round(Number(value)) % 360) + 360) % 360; +} + +function latitudeOr(value, fallback) { + if (!finite(value)) return fallback; + return ( + Math.round(clamp(Number(value), -90, 90) * COORDINATE_SCALE) / + COORDINATE_SCALE + + 0 + ); +} + +function longitudeOr(value, fallback) { + if (!finite(value)) return fallback; + let lon = Number(value); + // Wrap before scaling, so a huge finite input cannot overflow to NaN. + if (lon < -180 || lon > 180) lon = ((((lon + 180) % 360) + 360) % 360) - 180; + return Math.round(lon * COORDINATE_SCALE) / COORDINATE_SCALE + 0; +} + +function epochMsOr(value) { + if (!finite(value)) return null; + const seconds = Math.floor(Number(value) / 1000); + return seconds >= 1 && Number.isSafeInteger(seconds * 1000) + ? seconds * 1000 + : null; +} + +function nameOr(value, fallback) { + return typeof value === 'string' && NAME_GRAMMAR.test(value) + ? value + : fallback; +} + +function normalizeEntityId(entry, value) { + if (value === null || value === undefined) return null; + const raw = + typeof value === 'number' && Number.isFinite(value) + ? String(value) + : typeof value === 'string' + ? value.trim().toLowerCase() + : null; + if (raw === null || !TRACKING_ID_GRAMMAR.test(raw)) return null; + if (entry.kind === 'satellite' && !/^[1-9]\d{0,14}$/.test(raw)) return null; + return raw; +} + +function normalizeStyleParams(style, values) { + const registry = SHARE_VIEW_STYLE_PARAMS[style]; + if (!registry || !values || typeof values !== 'object') return null; + const out = {}; + for (const spec of registry) { + if (!Object.hasOwn(values, spec.key) || !finite(values[spec.key])) continue; + out[spec.key] = + Math.round(clamp(Number(values[spec.key]), spec.min, spec.max) * 100) / + 100; + } + return Object.keys(out).length ? out : null; +} + +/** + * What each style leaves the shareable settings at when it is selected in a + * fresh session: the app's first-load baseline (GLOBAL_POST_DEFAULTS), with + * the style's own preset (STYLE_PRESET_DEFAULTS) on top, and its shader + * parameters at their defaults (the preset's, else the shader's). Read from + * the tables the app applies, so a fresh view in any style encodes as its + * camera and style alone. + * + * These are format v1's frozen meanings. If the app's tables move, the test + * that pins this table fails: freeze the old values here then, rather than + * letting old links change meaning. + */ +export const SHARE_VIEW_STYLE_DEFAULTS = Object.freeze( + Object.fromEntries( + VIEW_STYLES.map((style) => { + const g = GLOBAL_POST_DEFAULTS; + const preset = STYLE_PRESET_DEFAULTS[style] ?? {}; + const detection = migrateDetectionState( + preset.detection?.mode?.toUpperCase?.() ?? g.detectionMode, + preset.detection?.densityPct ?? g.detectionDensity, + g.detectionDensity, + ); + const params = {}; + for (const spec of SHARE_VIEW_STYLE_PARAMS[style] ?? []) { + const value = + preset.styleParams?.[style]?.[spec.key] ?? + STYLES[style]?.uniforms?.[spec.key]?.default; + if (finite(value)) params[spec.key] = value; + } + return [ + style, + Object.freeze({ + bloom: preset.bloom?.enabled ?? g.bloom.enabled, + bloomIntensity: preset.bloom?.intensity ?? g.bloom.intensity, + sharpen: preset.sharpen?.enabled ?? g.sharpen.enabled, + sharpenIntensity: preset.sharpen?.intensity ?? g.sharpen.intensity, + hudVariant: preset.hudVariant ?? g.hudVariant, + hudVisible: preset.hudVisible ?? g.hudVisible, + detectionMode: detection.enabled ? detection.profile : 'OFF', + detectionDensity: detection.densityPct, + detectionAllocation: normalizeAllocationStrategy( + g.detectionAllocation, + ), + detectionFadePct: g.detectionFadePct, + detectionOutsideOpacityPct: g.detectionOutsideOpacityPct, + // Leaving Normal switches the ring off (VisualSettings.setStyle). + celestialRing: style === 'normal' ? g.celestialRing : false, + styleParams: Object.freeze(normalizeStyleParams(style, params)), + }), + ]; + }), + ), +); + +function normalizePanelState(panelState) { + if (!panelState || !Array.isArray(panelState.specs)) return null; + const byId = new Map(); + for (const spec of panelState.specs) { + if (spec && PANEL_BY_ID.has(spec.id) && typeof spec.collapsed === 'boolean') + byId.set(spec.id, spec); + } + const specs = []; + for (const panel of SHARE_VIEW_PANELS) { + const spec = byId.get(panel.id); + if (!spec) continue; + specs.push({ + id: panel.id, + collapsed: spec.collapsed, + pinned: + panel.pinnable && typeof spec.pinned === 'boolean' ? spec.pinned : null, + }); + } + return specs.length ? { specs } : null; +} + +function normalizeFollowOffset(offset) { + const d = SHARE_VIEW_FOLLOW_OFFSET_DEFAULT; + const source = offset && typeof offset === 'object' ? offset : {}; + return { + range: intOr(source.range, d.range, 1, MAX_FOLLOW_RANGE_M), + heading: wrapAngle(source.heading, d.heading), + pitch: intOr(source.pitch, d.pitch, -90, 90), + }; +} + +function normalizeLastKnown(fix) { + if (!fix || typeof fix !== 'object') return null; + if (!finite(fix.lat) || !finite(fix.lon)) return null; + return { + lat: latitudeOr(fix.lat, 0), + lon: longitudeOr(fix.lon, 0), + alt: intOr(fix.alt, 0, -MAX_ALTITUDE_M, MAX_ALTITUDE_M), + observedAtMs: epochMsOr(fix.observedAtMs), + }; +} + +function thingView(kind, id, offset = null, lastKnown = null) { + return { + kind: 'thing', + entity: { kind, id }, + offset: normalizeFollowOffset(offset), + lastKnown: normalizeLastKnown(lastKnown), + }; +} + +function normalizeView(view) { + if (view?.kind !== 'thing') return { kind: 'place' }; + const entry = ENTITY_BY_KIND.get(view.entity?.kind); + const id = entry ? normalizeEntityId(entry, view.entity?.id) : null; + if (!id) return { kind: 'place' }; + return thingView(entry.kind, id, view.offset, view.lastKnown); +} + +/** The camera fields of `state`, at the format's precision. */ +function setCamera(state, camera) { + state.lat = latitudeOr(camera.lat, 0); + state.lon = longitudeOr(camera.lon, 0); + state.alt = intOr(camera.alt, 0, -MAX_ALTITUDE_M, MAX_ALTITUDE_M); + state.heading = wrapAngle(camera.heading, 0); + state.pitch = intOr(camera.pitch, 0, -90, 90); + state.roll = wrapAngle(camera.roll, 0); +} + +/** + * Lift a long-form tracking selection out of the layer options into a thing + * view. In this format the view, not a layer option, says what is followed. + */ +function liftTrackedEntity(layerState, view) { + let lifted = view; + for (const entry of SHARE_VIEW_ENTITY_KINDS) { + const policy = entry.tracking + ? SHARE_TRACKING_RESTORE_POLICIES[entry.tracking] + : null; + if (!policy) continue; + const options = layerState.options[policy.optionOwner]; + const id = normalizeEntityId(entry, options[policy.optionKey]); + options[policy.optionKey] = null; + if (id && lifted.kind === 'place') lifted = thingView(entry.kind, id); + } + return lifted; +} + +/** + * The canonical form of a share-view state. Missing or invalid fields take + * their defaults, numbers take the format's precision, and a long-form + * tracking selection becomes a thing view. A thing view with a last-known fix + * takes its camera from the fix and the follow offset, and a thing view's + * layer is always on. `decodeShareView` returns this + * form, and `encodeShareView(state)` decodes back to `normalizeShareViewState(state)`. + */ +export function normalizeShareViewState(input = {}) { + const source = input && typeof input === 'object' ? input : {}; + const style = VIEW_STYLES.includes(source.style) ? source.style : 'normal'; + // Style-applied settings default to what this style applies. + const d = createDefaultShareViewState(style); + const state = {}; + state.lat = latitudeOr(source.lat, d.lat); + state.lon = longitudeOr(source.lon, d.lon); + state.alt = intOr(source.alt, d.alt, -MAX_ALTITUDE_M, MAX_ALTITUDE_M); + state.heading = wrapAngle(source.heading, d.heading); + state.pitch = intOr(source.pitch, d.pitch, -90, 90); + state.roll = wrapAngle(source.roll, d.roll); + state.style = style; + // The style's own parameters, with any valid ones the source sets on top. + state.styleParams = normalizeStyleParams(style, { + ...d.styleParams, + ...normalizeStyleParams(style, source.styleParams), + }); + for (const flag of SHARE_VIEW_FLAGS) + state[flag.key] = + typeof source[flag.key] === 'boolean' ? source[flag.key] : d[flag.key]; + state.bloomIntensity = intOr( + source.bloomIntensity, + d.bloomIntensity, + 0, + MAX_UNSIGNED_SETTING, + ); + state.bloomVersion = intOr( + source.bloomVersion, + d.bloomVersion, + 0, + MAX_UNSIGNED_SETTING, + ); + state.sharpenIntensity = intOr( + source.sharpenIntensity, + d.sharpenIntensity, + 0, + MAX_UNSIGNED_SETTING, + ); + state.hudVariant = nameOr(source.hudVariant, d.hudVariant); + const detection = migrateDetectionState( + typeof source.detectionMode === 'string' && source.detectionMode + ? source.detectionMode + : d.detectionMode, + finite(source.detectionDensity) + ? Number(source.detectionDensity) + : d.detectionDensity, + d.detectionDensity, + ); + state.detectionMode = detection.enabled ? detection.profile : 'OFF'; + state.detectionDensity = detection.densityPct; + state.detectionAllocation = normalizeAllocationStrategy( + source.detectionAllocation, + d.detectionAllocation, + ); + state.detectionFadePct = intOr( + source.detectionFadePct, + d.detectionFadePct, + 0, + 40, + ); + state.detectionOutsideOpacityPct = intOr( + source.detectionOutsideOpacityPct, + d.detectionOutsideOpacityPct, + 0, + 100, + ); + state.scopeFeatherPct = finite(source.scopeFeatherPct) + ? clampScopeFeatherPct(Number(source.scopeFeatherPct)) + : d.scopeFeatherPct; + state.scopeTerminusPct = intOr( + source.scopeTerminusPct, + null, + SCOPE_TERMINUS_MIN_PCT, + SCOPE_TERMINUS_MAX_PCT, + ); + state.mapStack = nameOr(source.mapStack, d.mapStack); + state.layerState = source.layerState + ? normalizeLayerState(source.layerState) + : null; + state.panelState = normalizePanelState(source.panelState); + state.sharedAtMs = epochMsOr(source.sharedAtMs); + let view = normalizeView(source.view); + if (state.layerState) view = liftTrackedEntity(state.layerState, view); + if (view.kind === 'thing') { + // A fix without its own time is as of the link's creation. + if (view.lastKnown?.observedAtMs === null && state.sharedAtMs !== null) + view.lastKnown.observedAtMs = state.sharedAtMs; + // A thing view's camera is where following it puts the camera, and its + // layer is on: neither is stored separately. + if (view.lastKnown) + setCamera(state, followCameraFor(view.lastKnown, view.offset)); + const { layerId } = ENTITY_BY_KIND.get(view.entity.kind); + if (state.layerState && !state.layerState.enabledLayerIds.includes(layerId)) + state.layerState = normalizeLayerState({ + ...state.layerState, + enabledLayerIds: [...state.layerState.enabledLayerIds, layerId], + }); + } + return { view, ...state }; +} + +/* ------------------------------------------------------------------ */ +/* Token and id helpers. */ +/* ------------------------------------------------------------------ */ + +/** + * The permanent number of a layer-state token: one character is its index in + * the base-36 alphabet, two characters are 36 + first × 36 + second. The + * token ledger never reassigns a token, so neither does this. + */ +export function layerTokenNumber(token) { + const a = LAYER_STATE_TOKEN_ALPHABET.indexOf(token[0]); + if (token.length === 1) return a; + return 36 + a * 36 + LAYER_STATE_TOKEN_ALPHABET.indexOf(token[1]); +} + +export function layerTokenFromNumber(number) { + const base = LAYER_STATE_TOKEN_ALPHABET.length; + if (number < base) return LAYER_STATE_TOKEN_ALPHABET[number]; + const rest = number - base; + if (rest >= base * base) return null; + return ( + LAYER_STATE_TOKEN_ALPHABET[Math.floor(rest / base)] + + LAYER_STATE_TOKEN_ALPHABET[rest % base] + ); +} + +const REGISTRY_BY_TOKEN = new Map( + LAYER_STATE_REGISTRY.map((entry) => [entry.token, entry]), +); +const charNumber = (char) => LAYER_STATE_TOKEN_ALPHABET.indexOf(char); +const CANONICAL_INTEGER = /^(0|-?[1-9]\d{0,14})$/; + +function writeEntityId(writer, entry, id) { + const field = entry.code; + if (entry.idForm === 'hex24' && /^[0-9a-f]{6}$/.test(id)) + return writer.uintField(field, Number.parseInt(id, 16)); + if (entry.idForm === 'decimal' && /^[1-9]\d{0,14}$/.test(id)) + return writer.uintField(field, Number(id)); + return writer.textField(field, id); +} + +/* ------------------------------------------------------------------ */ +/* Encoder. */ +/* ------------------------------------------------------------------ */ + +function writeEnum(writer, field, value) { + const code = SHARE_VIEW_ENUMS[field.enum].indexOf(value); + if (code >= 0) writer.uintField(field.number, code); + else writer.textField(field.number, value); +} + +function encodeLayers(writer, layerState, impliedLayerId) { + const bitset = []; + for (const id of layerState.enabledLayerIds) { + if (id === impliedLayerId) continue; + const number = layerTokenNumber(REGISTRY_BY_ID.get(id).token); + const byte = Math.floor(number / 8); + while (bitset.length <= byte) bitset.push(0); + bitset[byte] |= 1 << (number % 8); + } + writer.bytesField(fieldNumber('layerState'), bitset); + const blob = new WireWriter(); + for (const [ownerId, options] of Object.entries(layerState.options)) { + const ownerToken = REGISTRY_BY_ID.get(ownerId).token; + for (const spec of optionSpecs(ownerId)) { + const current = options[spec.key]; + if (current === null || current === layerOptionAbsentValue(ownerId, spec)) + continue; + const value = spec.encode(options[spec.key]); + blob.varint(layerTokenNumber(ownerToken)); + const isInteger = + CANONICAL_INTEGER.test(value) && + Math.abs(Number(value)) <= MAX_ZIGZAG_MAGNITUDE; + blob.varint(charNumber(spec.token) * 2 + (isInteger ? 0 : 1)); + if (isInteger) blob.varint(zigzag(Number(value))); + else { + const bytes = asciiBytes(value); + blob.varint(bytes.length); + blob.bytes.push(...bytes); + } + } + } + if (blob.bytes.length) + writer.bytesField(fieldNumber('layerOptions'), blob.finish()); +} + +const triState = (value) => (value === null ? 0 : value ? 2 : 1); + +function encodePanels(writer, panelState) { + const byId = new Map(panelState.specs.map((spec) => [spec.id, spec])); + const blob = new WireWriter(); + for (const panel of SHARE_VIEW_PANELS) { + const spec = byId.get(panel.id); + const defaultPinned = panel.pinnable ? false : null; + if ( + spec && + spec.collapsed === panel.collapsed && + spec.pinned === defaultPinned + ) + continue; + const collapsed = spec ? triState(spec.collapsed) : 0; + const pinned = spec ? triState(spec.pinned) : 0; + blob.varint(panel.number * 16 + collapsed * 4 + pinned); + } + writer.bytesField(fieldNumber('panelState'), blob.finish()); +} + +function encodeStyleParams(writer, style, values) { + const defaults = SHARE_VIEW_STYLE_DEFAULTS[style].styleParams ?? {}; + const blob = new WireWriter(); + for (const spec of SHARE_VIEW_STYLE_PARAMS[style]) { + // Only the parameters that differ from the style's own. + if (!Object.hasOwn(values, spec.key)) continue; + if (values[spec.key] === defaults[spec.key]) continue; + blob + .varint(charNumber(spec.token)) + .varint(Math.round(values[spec.key] * 100)); + } + if (blob.bytes.length) + writer.bytesField(fieldNumber('styleParams'), blob.finish()); +} + +const CAMERA_KEYS = new Set(['lat', 'lon', 'alt', 'heading', 'pitch', 'roll']); +const coordinateInt = (degrees) => Math.round(degrees * COORDINATE_SCALE); +const signedAngle = (degrees) => (degrees > 180 ? degrees - 360 : degrees); +const epochSeconds = (ms) => ms / 1000 - SHARE_VIEW_EPOCH_S; + +function encodeThing(writer, view, state) { + const entry = ENTITY_BY_KIND.get(view.entity.kind); + const f = SHARE_VIEW_THING_FIELDS; + const d = SHARE_VIEW_FOLLOW_OFFSET_DEFAULT; + const thing = new WireWriter(); + writeEntityId(thing, entry, view.entity.id); + const { range, heading, pitch } = view.offset; + if (range !== d.range) thing.uintField(f.range, range); + if (heading !== d.heading) thing.sintField(f.heading, signedAngle(heading)); + if (pitch !== d.pitch) thing.sintField(f.pitch, pitch); + const fix = view.lastKnown; + if (fix) { + // Both coordinates are always written: their presence marks the fix. + thing.sintField(f.lat, coordinateInt(fix.lat)); + thing.sintField(f.lon, coordinateInt(fix.lon)); + if (fix.alt) thing.sintField(f.alt, fix.alt); + const observed = fix.observedAtMs; + // An absent age is 0: a fix taken as the link is made costs nothing. + if (observed !== null && state.sharedAtMs !== null) { + if (observed !== state.sharedAtMs) + thing.sintField(f.ageS, (state.sharedAtMs - observed) / 1000); + } else if (observed !== null) + thing.sintField(f.observedAt, epochSeconds(observed)); + } + writer.bytesField(fieldNumber('thing'), thing.finish()); +} + +/** The base64url text for `bytes` followed by their CRC-8. */ +function sealPayload(bytes) { + const sealed = new Uint8Array(bytes.length + 1); + sealed.set(bytes); + sealed[bytes.length] = crc8(bytes); + return encodeBase64Url(sealed); +} + +/** + * Encode a share-view state as its share code: base64url text, the `` + * in `/share/`. The state is normalized first; omitted or default + * fields cost nothing. + */ +export function encodeShareView(input) { + const state = normalizeShareViewState(input); + const thing = state.view.kind === 'thing' ? state.view : null; + // A thing view with a fix derives its camera from it (followCameraFor). + const cameraDerived = Boolean(thing?.lastKnown); + const impliedLayerId = thing + ? ENTITY_BY_KIND.get(thing.entity.kind).layerId + : null; + const writer = new WireWriter(); + writer.bytes.push(SHARE_VIEW_FORMAT_VERSION); + for (const field of SHARE_VIEW_FIELDS) { + if (cameraDerived && CAMERA_KEYS.has(field.key)) continue; + switch (field.key) { + case 'view': + // Absent means `thing` when field 13 is present, else `place`. + if (state.view.kind !== (thing ? 'thing' : 'place')) + writeEnum(writer, field, state.view.kind); + break; + case 'lat': + case 'lon': + if (state[field.key] !== field.default) + writer.sintField(field.number, coordinateInt(state[field.key])); + break; + case 'heading': + case 'roll': + if (state[field.key] !== field.default) + writer.sintField(field.number, signedAngle(state[field.key])); + break; + case 'sharedAtMs': + if (state.sharedAtMs !== null) + writer.sintField(field.number, epochSeconds(state.sharedAtMs)); + break; + case 'flags': { + let bits = 0; + for (const flag of SHARE_VIEW_FLAGS) + if (state[flag.key] !== defaultOf(flag, state.style)) + bits += 2 ** flag.bit; + if (bits) writer.uintField(field.number, bits); + break; + } + case 'layerState': + if (state.layerState) + encodeLayers(writer, state.layerState, impliedLayerId); + break; + case 'layerOptions': + break; // written together with layerState + case 'panelState': + if (state.panelState) encodePanels(writer, state.panelState); + break; + case 'thing': + if (thing) encodeThing(writer, thing, state); + break; + case 'styleParams': + if (state.styleParams) + encodeStyleParams(writer, state.style, state.styleParams); + break; + default: + if (state[field.key] === defaultOf(field, state.style)) break; + if (field.type === 'enum') writeEnum(writer, field, state[field.key]); + else if (field.type === 'sint') + writer.sintField(field.number, state[field.key]); + else writer.uintField(field.number, state[field.key]); + } + } + return sealPayload(writer.finish()); +} + +/* ------------------------------------------------------------------ */ +/* Decoder. */ +/* ------------------------------------------------------------------ */ + +/** Collects every field, code or entry a v1 decoder did not understand. */ +class UnknownLog { + constructor() { + this.entries = []; + } + field(scope, field, wireType) { + this.entries.push({ scope, field, wireType }); + } + code(scope, code) { + this.entries.push({ scope, code }); + } +} + +/** + * Read one message's fields, enforcing each known field's wire types and + * rejecting repeats, skipping (and logging) unknown fields. + * `known` maps field number → allowed wire types. + */ +function readMessage(reader, known, scope, unknown) { + const values = new Map(); + while (!reader.done) { + const { field, wireType } = reader.tag(); + const allowed = known.get(field); + if (!allowed) { + unknown.field(scope, field, wireType); + reader.skip(wireType); + continue; + } + if (!allowed.includes(wireType)) + throw new ShareWireError('wire-type-mismatch', `${scope}.${field}`); + if (values.has(field)) + throw new ShareWireError('duplicate-field', `${scope}.${field}`); + values.set( + field, + wireType === WIRE_LEN + ? { len: reader.lengthDelimited() } + : { varint: reader.varint() }, + ); + } + return values; +} + +const WIRE_TYPES_BY_TYPE = Object.freeze({ + uint: [WIRE_VARINT], + sint: [WIRE_VARINT], + angle: [WIRE_VARINT], + coord: [WIRE_VARINT], + epoch: [WIRE_VARINT], + flags: [WIRE_VARINT], + enum: [WIRE_VARINT, WIRE_LEN], + bytes: [WIRE_LEN], +}); +const TOP_LEVEL_WIRE_TYPES = new Map( + SHARE_VIEW_FIELDS.filter((f) => !f.retired).map((f) => [ + f.number, + WIRE_TYPES_BY_TYPE[f.type], + ]), +); +const THING_WIRE_TYPES = new Map([ + ...SHARE_VIEW_ENTITY_KINDS.map((e) => [e.code, [WIRE_VARINT, WIRE_LEN]]), + ...Object.values(SHARE_VIEW_THING_FIELDS).map((n) => [n, [WIRE_VARINT]]), +]); + +const unz = unzigzag; +const CLOSED_ENUMS = new Set(['detectionMode', 'detectionAllocation']); + +function readEnum(entry, field, unknown) { + if (!entry) return undefined; + if (entry.len) { + const text = asciiText(entry.len.rest()); + // Detection settings are closed sets the app interprets; an unknown name + // must not reach the long form's lenient migration (which turns it ON). + if ( + CLOSED_ENUMS.has(field.key) && + !SHARE_VIEW_ENUMS[field.enum].includes(text) + ) { + unknown.code(`enum:${field.key}`, text); + return undefined; + } + return text; + } + const name = SHARE_VIEW_ENUMS[field.enum][entry.varint]; + if (name === undefined) unknown.code(`enum:${field.key}`, entry.varint); + return name; +} + +function decodeLayers(bitsReader, optionsReader, unknown) { + const enabledLayerIds = []; + const bits = bitsReader.rest(); + for (let byte = 0; byte < bits.length; byte += 1) { + for (let bit = 0; bit < 8; bit += 1) { + if (!(bits[byte] & (1 << bit))) continue; + const number = byte * 8 + bit; + const entry = REGISTRY_BY_TOKEN.get(layerTokenFromNumber(number)); + if (entry) enabledLayerIds.push(entry.id); + else unknown.code('layer', number); + } + } + const options = {}; + const seen = new Set(); + const reader = optionsReader; + while (reader && !reader.done) { + const layerNumber = reader.varint(); + const key = reader.varint(); + let value; + if (key % 2 === 0) value = String(unz(reader.varint())); + else { + const length = reader.varint(); + if (length > MAX_OPTION_VALUE_CHARS || length > reader.end - reader.pos) + throw new ShareWireError('truncated', 'layer option'); + value = asciiText(reader.bytes.slice(reader.pos, reader.pos + length)); + reader.pos += length; + } + const slot = `${layerNumber}.${Math.floor(key / 2)}`; + if (seen.has(slot)) throw new ShareWireError('duplicate-entry', slot); + seen.add(slot); + // Options are filed under their owner layer only (never a mirror such as + // `military`), so each option has exactly one spelling. + const layerEntry = REGISTRY_BY_TOKEN.get(layerTokenFromNumber(layerNumber)); + const ownerId = + layerEntry?.optionOwner === layerEntry?.id + ? layerEntry?.optionOwner + : null; + const optionToken = LAYER_STATE_TOKEN_ALPHABET[Math.floor(key / 2)]; + const spec = ownerId + ? optionSpecs(ownerId).find( + (candidate) => candidate.token === optionToken, + ) + : null; + const decoded = spec ? spec.decode(value) : null; + if (decoded === null || decoded === undefined) { + unknown.code('layer-option', slot); + continue; + } + options[ownerId] ??= {}; + options[ownerId][spec.key] = decoded; + } + // Fill every option the payload did not carry with ITS frozen meaning, not + // the app's current default (normalization would substitute that). + for (const ownerId of Object.keys(SHARE_VIEW_LAYER_OPTION_ABSENT)) { + options[ownerId] ??= {}; + for (const spec of optionSpecs(ownerId)) { + if (!Object.hasOwn(options[ownerId], spec.key)) + options[ownerId][spec.key] = layerOptionAbsentValue(ownerId, spec); + } + } + return normalizeLayerState({ enabledLayerIds, options }); +} + +function decodePanels(reader, unknown) { + const entries = new Map(); + while (!reader.done) { + const value = reader.varint(); + const number = Math.floor(value / 16); + if (entries.has(number)) + throw new ShareWireError('duplicate-entry', `panel ${number}`); + entries.set(number, { + collapsed: Math.floor(value / 4) % 4, + pinned: value % 4, + }); + } + for (const [number, entry] of entries) { + if ( + !PANEL_BY_NUMBER.has(number) || + entry.collapsed === 3 || + entry.pinned === 3 + ) + unknown.code('panel', number); + } + const specs = []; + for (const panel of SHARE_VIEW_PANELS) { + const entry = entries.get(panel.number); + if (entry) { + // 0 is "no opinion"; 3 is a value from a newer format, also ignored. + if (entry.collapsed === 0 || entry.collapsed === 3 || entry.pinned === 3) + continue; + specs.push({ + id: panel.id, + collapsed: entry.collapsed === 2, + pinned: + panel.pinnable && entry.pinned !== 0 ? entry.pinned === 2 : null, + }); + } else { + specs.push({ + id: panel.id, + collapsed: panel.collapsed, + pinned: panel.pinnable ? false : null, + }); + } + } + return { specs }; +} + +function decodeStyleParams(reader, style, unknown) { + const registry = SHARE_VIEW_STYLE_PARAMS[style] || []; + const values = {}; + const seen = new Set(); + while (!reader.done) { + const index = reader.varint(); + const token = LAYER_STATE_TOKEN_ALPHABET[index]; + const scaled = reader.varint(); + if (seen.has(index)) + throw new ShareWireError('duplicate-entry', 'style parameter'); + seen.add(index); + const spec = registry.find((candidate) => candidate.token === token); + if (!spec) { + unknown.code('style-param', `${style}.${token ?? '?'}`); + continue; + } + values[spec.key] = clamp(scaled / 100, spec.min, spec.max); + } + return values; +} + +function decodeEntityId(entry, value) { + if (!entry) return null; + if (value.len) return normalizeEntityId(entry, asciiText(value.len.rest())); + if (entry.idForm === 'hex24') + return value.varint <= 0xffffff + ? value.varint.toString(16).padStart(6, '0') + : null; + return normalizeEntityId(entry, String(value.varint)); +} + +function decodeThing(reader, createdAtS, unknown) { + const unknownBefore = unknown.entries.length; + const fields = readMessage(reader, THING_WIRE_TYPES, 'thing', unknown); + const F = SHARE_VIEW_THING_FIELDS; + const d = SHARE_VIEW_FOLLOW_OFFSET_DEFAULT; + const signed = (n) => unz(fields.get(n)?.varint ?? 0); + const offset = { + range: fields.get(F.range)?.varint ?? d.range, + heading: fields.has(F.heading) ? signed(F.heading) : d.heading, + pitch: fields.has(F.pitch) ? signed(F.pitch) : d.pitch, + }; + let lastKnown = null; + if (fields.has(F.lat) || fields.has(F.lon)) { + let observedAtMs = null; + if (fields.has(F.observedAt)) + observedAtMs = (SHARE_VIEW_EPOCH_S + signed(F.observedAt)) * 1000; + else if (createdAtS !== null) + observedAtMs = (createdAtS - signed(F.ageS)) * 1000; + lastKnown = { + lat: signed(F.lat) / COORDINATE_SCALE, + lon: signed(F.lon) / COORDINATE_SCALE, + alt: signed(F.alt), + observedAtMs, + }; + if (Math.abs(lastKnown.lat) > 90 || Math.abs(lastKnown.lon) > 180) + throw new ShareWireError('out-of-range', 'thing fix'); + } + // The camera this view falls back to, whatever happens to the entity. + const camera = lastKnown + ? followCameraFor(lastKnown, normalizeFollowOffset(offset)) + : null; + const present = SHARE_VIEW_ENTITY_KINDS.filter((e) => fields.has(e.code)); + if (present.length > 1) + throw new ShareWireError('duplicate-field', 'thing entity id'); + const entry = present[0]; + if (!entry) { + // A newer app's entity kind uses a thing field this decoder skipped. + const newerKind = unknown.entries.length > unknownBefore; + return { + camera, + fallback: newerKind ? 'unknown-entity-kind' : 'missing-entity-kind', + }; + } + const id = decodeEntityId(entry, fields.get(entry.code)); + if (!id) return { camera, fallback: 'invalid-entity-id' }; + return { camera, view: thingView(entry.kind, id, offset, lastKnown) }; +} + +/** + * Decode a share code (the `` in `/share/`). + * + * Returns `{ ok: true, state, unknownFields?, fallback? }` with a normalized + * state, or `{ ok: false, reason }`. It never throws. A code whose check byte + * does not match is rejected (`checksum`), so a mistyped code never opens a + * different view. `unknownFields` lists what a newer encoder wrote that this + * decoder skipped; `fallback` says why a view that asked to follow something + * opens as a place at its camera instead. A created-at time in the future + * (after `nowMs`) is dropped. + */ +export function decodeShareView(code, { nowMs = Date.now() } = {}) { + try { + if (typeof code !== 'string') return { ok: false, reason: 'not-a-string' }; + if (code.length > MAX_SHARE_VIEW_CHARS) + return { ok: false, reason: 'too-long' }; + const sealed = decodeBase64Url(code); + if (sealed.length < 2) return { ok: false, reason: 'truncated' }; + const bytes = sealed.subarray(0, sealed.length - 1); + if (crc8(bytes) !== sealed[sealed.length - 1]) + return { ok: false, reason: 'checksum' }; + if (bytes[0] !== SHARE_VIEW_FORMAT_VERSION) + return { ok: false, reason: 'unsupported-version' }; + const unknown = new UnknownLog(); + const fields = readMessage( + new WireReader(bytes, 1), + TOP_LEVEL_WIRE_TYPES, + 'view', + unknown, + ); + const get = (key) => fields.get(fieldNumber(key)); + + const raw = {}; + for (const field of SHARE_VIEW_FIELDS) { + const entry = fields.get(field.number); + if ( + !entry || + field.type === 'bytes' || + field.type === 'flags' || + field.key === 'view' + ) + continue; + if (field.type === 'enum') + raw[field.key] = readEnum(entry, field, unknown); + else if (field.type === 'coord') + raw[field.key] = unz(entry.varint) / COORDINATE_SCALE; + else if (field.type === 'sint' || field.type === 'angle') + raw[field.key] = unz(entry.varint); + else if (field.type === 'uint') raw[field.key] = entry.varint; + } + for (const key of ['lat', 'lon']) { + raw[key] ??= 0; + if (Math.abs(raw[key]) > (key === 'lat' ? 90 : 180)) + return { ok: false, reason: 'out-of-range' }; + } + + if (raw.style !== undefined && !VIEW_STYLES.includes(raw.style)) { + if (get('style').len) unknown.code('enum:style', raw.style); + raw.style = undefined; + } + for (const key of ['hudVariant', 'mapStack']) { + if (raw[key] !== undefined && !NAME_GRAMMAR.test(raw[key])) + unknown.code(`enum:${key}`, raw[key]); + } + + const flags = get('flags')?.varint ?? 0; + for (const flag of SHARE_VIEW_FLAGS) + raw[flag.key] = + (Math.floor(flags / 2 ** flag.bit) % 2 === 1) !== + defaultOf(flag, raw.style ?? 'normal'); + const knownBits = 2 ** SHARE_VIEW_FLAGS.length - 1; + if (flags > knownBits) + unknown.code('flags', flags - (flags % (knownBits + 1))); + + let createdAtS = null; + if (get('sharedAtMs')) { + createdAtS = SHARE_VIEW_EPOCH_S + unz(get('sharedAtMs').varint); + const ms = createdAtS * 1000; + raw.sharedAtMs = + createdAtS >= 1 && Number.isSafeInteger(ms) && ms <= nowMs ? ms : null; + } + + if (get('layerState')) + raw.layerState = decodeLayers( + get('layerState').len, + get('layerOptions')?.len ?? null, + unknown, + ); + else if (get('layerOptions')) + unknown.code('layer-option', 'without-layers'); + if (get('panelState')) + raw.panelState = decodePanels(get('panelState').len, unknown); + if (get('styleParams')) + raw.styleParams = decodeStyleParams( + get('styleParams').len, + raw.style ?? 'normal', + unknown, + ); + + let fallback; + const thing = get('thing') + ? decodeThing(get('thing').len, createdAtS, unknown) + : null; + // A thing with a last-known fix carries the view's camera. + if (thing?.camera) Object.assign(raw, thing.camera); + const viewEntry = get('view'); + const viewKind = viewEntry + ? viewEntry.len + ? asciiText(viewEntry.len.rest()) + : (SHARE_VIEW_ENUMS.viewKind[viewEntry.varint] ?? viewEntry.varint) + : thing + ? 'thing' + : 'place'; + if (viewKind === 'thing') { + if (thing?.view) raw.view = thing.view; + else fallback = thing?.fallback ?? 'missing-thing'; + } else if (viewKind !== 'place') { + unknown.code('enum:view', viewKind); + fallback = 'unknown-view-kind'; + } + + const state = normalizeShareViewState(raw); + const result = { ok: true, state }; + if (unknown.entries.length) result.unknownFields = unknown.entries; + if (fallback) result.fallback = fallback; + return result; + } catch (error) { + if (error instanceof ShareWireError) + return { ok: false, reason: error.reason }; + return { ok: false, reason: 'internal-error' }; + } +} diff --git a/src/share/viewCodec.test.mjs b/src/share/viewCodec.test.mjs new file mode 100644 index 000000000..4ec2b967c --- /dev/null +++ b/src/share/viewCodec.test.mjs @@ -0,0 +1,1307 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + LAYER_STATE_REGISTRY, + LAYER_STATE_TOKEN_RESERVATIONS, + REGISTERED_LAYER_IDS, + createDefaultLayerState, + optionSpecs, +} from '../data/layerState.js'; +import { VIEW_FOLLOW_KINDS, VIEW_MAPS, VIEW_STYLES } from '../view/index.js'; +import { HUD_LAYOUTS } from '../hudLayoutPolicy.js'; +import { ALLOCATION_STRATEGIES } from '../data/detectionPolicy.js'; +import { BLOOM_INTENSITY_DEFAULT, BLOOM_SCALE_VERSION } from '../bloom.js'; +import { + pick, + randomLayerState, + rng, +} from '../testSupport/shareViewFixtures.mjs'; +import { + SCOPE_TERMINUS_MAX_PCT, + SCOPE_TERMINUS_MIN_PCT, +} from '../scopeMask.js'; +import { + decodePanelStateParams, + decodeStyleParamState, + encodeStyleParamState, +} from '../sharelink.js'; +import { + WIRE_FIXED32, + WIRE_FIXED64, + WireWriter, + crc8, + decodeBase64Url, + encodeBase64Url, +} from './wire.js'; +import { + MAX_SHARE_VIEW_CHARS, + SHARE_VIEW_ENTITY_KINDS, + SHARE_VIEW_ENUMS, + SHARE_VIEW_EPOCH_S, + SHARE_VIEW_FIELDS, + BY_STYLE, + SHARE_VIEW_FLAGS, + SHARE_VIEW_FOLLOW_OFFSET_DEFAULT, + SHARE_VIEW_STYLE_DEFAULTS, + SHARE_VIEW_LAYER_OPTION_ABSENT, + SHARE_VIEW_PANELS, + SHARE_VIEW_STYLE_PARAMS, + createDefaultShareViewState, + decodeShareView, + encodeShareView, + followCameraFor, + followOffsetFromEnu, + followOffsetToEnu, + layerTokenFromNumber, + layerTokenNumber, + normalizeShareViewState, +} from './viewCodec.js'; + +const NOW_MS = Date.UTC(2026, 9, 26, 12, 0, 0); +const decode = (hash) => decodeShareView(hash, { nowMs: NOW_MS }); +/** The payload bytes of a code, without its check byte. */ +const payloadBytes = (code) => decodeBase64Url(code).slice(0, -1); +/** The code for payload bytes: base64url of the bytes and their CRC-8. */ +const hashOf = (bytes) => encodeBase64Url([...bytes, crc8(bytes)]); + +/** Decode and require success, returning the result. */ +function decodeOk(hash) { + const result = decode(hash); + assert.equal(result.ok, true, `decode failed: ${result.reason}`); + return result; +} + +function assertRoundTrip(state) { + const hash = encodeShareView(state); + const result = decodeOk(hash); + assert.deepEqual(result.state, normalizeShareViewState(state)); + assert.equal(result.unknownFields, undefined); + assert.equal(result.fallback, undefined); + assert.equal(encodeShareView(result.state), hash, 'encoding is canonical'); + return hash; +} + +function randomState(random) { + const int = (min, max) => min + Math.floor(random() * (max - min + 1)); + const maybe = (p, value) => (random() < p ? value : undefined); + const style = pick(random, VIEW_STYLES); + const params = SHARE_VIEW_STYLE_PARAMS[style]; + const lat = int(-900000, 900000) / 1e4; + const lon = int(-1800000, 1800000) / 1e4; + const state = { + lat, + lon, + alt: pick(random, [800, int(-400, 50000), int(0, 40_000_000)]), + heading: int(0, 359), + pitch: int(-90, 90), + roll: pick(random, [0, int(0, 359)]), + style, + styleParams: + params && random() < 0.6 + ? Object.fromEntries( + params + .filter(() => random() < 0.7) + .map((s) => [s.key, int(s.min * 100, s.max * 100) / 100]), + ) + : null, + bloomIntensity: maybe(0.5, int(0, 100)), + bloomVersion: maybe(0.3, pick(random, [1, 2])), + sharpenIntensity: maybe(0.5, int(0, 100)), + hudVariant: maybe(0.5, pick(random, [...HUD_LAYOUTS, 'future-hud'])), + detectionMode: maybe(0.6, pick(random, SHARE_VIEW_ENUMS.detectionMode)), + detectionDensity: maybe(0.6, pick(random, [0, 25, 50, 75, 100])), + detectionAllocation: maybe(0.4, pick(random, ALLOCATION_STRATEGIES)), + detectionFadePct: maybe(0.5, int(0, 40)), + detectionOutsideOpacityPct: maybe(0.5, int(0, 100)), + scopeFeatherPct: maybe(0.5, int(0, 200)), + scopeTerminusPct: maybe( + 0.3, + int(SCOPE_TERMINUS_MIN_PCT, SCOPE_TERMINUS_MAX_PCT), + ), + mapStack: maybe(0.5, pick(random, [...VIEW_MAPS, 'future-map'])), + layerState: random() < 0.7 ? randomLayerState(random) : null, + panelState: + random() < 0.6 + ? { + specs: SHARE_VIEW_PANELS.filter(() => random() < 0.8).map( + (panel) => ({ + id: panel.id, + collapsed: random() < 0.5, + pinned: panel.pinnable + ? pick(random, [true, false, null]) + : null, + }), + ), + } + : null, + sharedAtMs: maybe(0.7, int(1, 2_100_000_000) * 1000), + }; + for (const flag of SHARE_VIEW_FLAGS) + if (random() < 0.5) state[flag.key] = random() < 0.5; + if (random() < 0.4) { + const entity = pick(random, SHARE_VIEW_ENTITY_KINDS); + const id = + entity.kind === 'satellite' + ? String(int(1, 99999)) + : entity.kind === 'vessel' + ? pick(random, [ + String(int(100000000, 999999999)), + '002442000', + 'tmp-7', + ]) + : pick(random, [ + int(0, 0xffffff).toString(16).padStart(6, '0'), + '~abc123', + 'adsb_x-1', + ]); + state.view = { + kind: 'thing', + entity: { kind: entity.kind, id }, + offset: + random() < 0.6 + ? { range: int(0, 200000), heading: int(0, 359), pitch: int(-90, 90) } + : null, + lastKnown: + random() < 0.6 + ? { + lat: Math.max(-90, Math.min(90, lat + int(-5000, 5000) / 1e4)), + lon: Math.max(-180, Math.min(180, lon + int(-5000, 5000) / 1e4)), + alt: int(-100, 500000), + observedAtMs: maybe(0.8, int(1, 2_100_000_000) * 1000), + } + : null, + }; + } + return state; +} + +test('an all-default view is the version byte and its check byte', () => { + const hash = encodeShareView(createDefaultShareViewState()); + assert.equal(hash, 'AQc'); + assert.deepEqual(decodeOk(hash).state, normalizeShareViewState({})); + assert.deepEqual(normalizeShareViewState({}), createDefaultShareViewState()); +}); + +test('each setting costs bytes only when it differs from its default', () => { + const base = payloadBytes(encodeShareView({})).length; + const changes = { + lat: 10, + lon: 10, + alt: 900, + heading: 1, + pitch: -36, + roll: 1, + sharedAtMs: NOW_MS, + style: 'noir', + mapStack: 'osm', + hudVariant: 'cyber', + bloomIntensity: 1, + bloomVersion: 1, + sharpenIntensity: 50, + detectionMode: 'SPARSE', + detectionDensity: 100, + detectionAllocation: 'WEIGHTED', + detectionFadePct: 8, + detectionOutsideOpacityPct: 2, + scopeFeatherPct: 99, + scopeTerminusPct: 95, + }; + const defaults = createDefaultShareViewState(); + for (const [key, value] of Object.entries(changes)) { + assert.equal( + payloadBytes(encodeShareView({ [key]: defaults[key] })).length, + base, + `${key} at its default is omitted`, + ); + const changed = { [key]: value }; + if (key === 'detectionMode') changed.detectionDensity = 75; + assert.ok( + payloadBytes(assertRoundTrip(changed)).length > base, + `${key} is encoded`, + ); + } + for (const flag of SHARE_VIEW_FLAGS) { + assert.equal( + payloadBytes(encodeShareView({ [flag.key]: defaults[flag.key] })).length, + base, + ); + assert.equal( + payloadBytes(assertRoundTrip({ [flag.key]: !defaults[flag.key] })).length, + base + 2, + ); + } + // All five booleans flipped still cost one field. + const flipped = Object.fromEntries( + SHARE_VIEW_FLAGS.map((f) => [f.key, !defaults[f.key]]), + ); + assert.equal(payloadBytes(assertRoundTrip(flipped)).length, base + 2); +}); + +test('a view just switched to any style costs its camera and style only', () => { + const camera = { lat: 48.8584, lon: 2.2945, alt: 1200, heading: 120 }; + const cameraOnly = payloadBytes(encodeShareView(camera)).length; + for (const style of VIEW_STYLES) { + // What the app holds right after selecting the style in a fresh session. + const fresh = { ...camera, ...SHARE_VIEW_STYLE_DEFAULTS[style], style }; + const bytes = payloadBytes(assertRoundTrip(fresh)).length; + assert.equal(bytes, cameraOnly + (style === 'normal' ? 0 : 2), style); + // Changes on top of the style's look still travel and round-trip. + const params = SHARE_VIEW_STYLE_PARAMS[style]; + const changed = { + ...fresh, + sharpen: false, + detectionMode: 'OFF', + detectionFadePct: 16, + ...(params + ? { + styleParams: { + ...fresh.styleParams, + [params[0].key]: params[0].min, + }, + } + : {}), + }; + assert.ok(payloadBytes(assertRoundTrip(changed)).length > bytes, style); + } +}); + +test('invalid style parameters keep the style’s own value', () => { + for (const bad of [undefined, NaN, 'x', null]) + assertRoundTrip({ style: 'noir', styleParams: { contrastAmt: bad } }); + assert.equal( + normalizeShareViewState({ style: 'noir', styleParams: { grainAmt: NaN } }) + .styleParams.grainAmt, + 0.5, + ); +}); + +test('every registered field round-trips at once', () => { + const layerState = createDefaultLayerState(); + layerState.enabledLayerIds = [ + 'flights', + 'satellites', + 'radio', + 'recent-imagery', + 'street-level', + ]; + Object.assign(layerState.options.flights, { + models3d: true, + models3dMode: 'all', + }); + Object.assign(layerState.options.radio, { + filter: 'genre:jazz & blues', + volume: 0.35, + }); + Object.assign(layerState.options['recent-imagery'], { + west: -12345678, + east: 12345678, + south: -8505110, + north: 8505110, + a: 'S30:2026-09-18', + b: 'VIIRS:2025-01-02', + mode: 2, + split: 37, + viirs: true, + }); + Object.assign(layerState.options['street-level'], { + mapillary: false, + providerSwitches: 'abc-1*def-0', + sinceDays: 365, + }); + const state = { + view: { + kind: 'thing', + entity: { kind: 'aircraft', id: 'a1b2c3' }, + offset: { range: 1500, heading: 300, pitch: -20 }, + lastKnown: { + lat: -33.8601, + lon: 151.2101, + alt: 10668, + observedAtMs: NOW_MS - 12_000, + }, + }, + lat: -33.8568, + lon: 151.2153, + alt: 12000, + heading: 270, + pitch: -12, + roll: 2, + style: 'thermal', + styleParams: { + sensitivity: 0.42, + bloom: 1, + mode: 0, + pixelation: 3.5, + palette: 0.25, + }, + bloom: true, + sharpen: false, + bloomIntensity: 84, + bloomVersion: 1, + sharpenIntensity: 65, + hudVariant: 'operator', + hudVisible: false, + detectionMode: 'SPARSE', + detectionDensity: 100, + detectionAllocation: 'WEIGHTED', + detectionFadePct: 16, + detectionOutsideOpacityPct: 5, + celestialRing: true, + scopeEnabled: false, + scopeFeatherPct: 35, + scopeTerminusPct: 97, + mapStack: 'esri-imagery', + layerState, + panelState: { + specs: SHARE_VIEW_PANELS.map((panel) => ({ + id: panel.id, + collapsed: !panel.collapsed, + pinned: panel.pinnable ? true : null, + })), + }, + sharedAtMs: NOW_MS, + }; + // A thing view derives its camera (roll 0), so the camera fields are + // checked on the same state as a place view. + const place = { ...state }; + delete place.view; + for (const subject of [state, place]) { + const normalized = normalizeShareViewState(subject); + for (const key of Object.keys(subject)) { + if (subject === state && key === 'roll') continue; + assert.notDeepEqual( + normalized[key], + createDefaultShareViewState()[key] ?? undefined, + key, + ); + } + assertRoundTrip(subject); + } +}); + +test('generated states round-trip exactly and encode canonically', () => { + const random = rng(0x5eed); + let things = 0; + for (let i = 0; i < 1500; i += 1) { + const state = randomState(random); + if (state.view?.kind === 'thing') things += 1; + const hash = encodeShareView(state); + const result = decodeShareView(hash, { nowMs: Number.MAX_SAFE_INTEGER }); + assert.equal(result.ok, true, `case ${i}: ${result.reason}`); + assert.deepEqual(result.state, normalizeShareViewState(state), `case ${i}`); + assert.equal(encodeShareView(result.state), hash, `case ${i} canonical`); + assert.ok(hash.length < MAX_SHARE_VIEW_CHARS, `case ${i} size`); + } + assert.ok(things > 400, 'generator covers thing views'); +}); + +test('normalization is idempotent', () => { + const random = rng(42); + for (let i = 0; i < 300; i += 1) { + const once = normalizeShareViewState(randomState(random)); + assert.deepEqual(normalizeShareViewState(once), once); + } +}); + +test('precision: coordinates 1e-4 degrees, angles and lengths whole units', () => { + const { state } = decodeOk( + encodeShareView({ + lat: 30.26724, + lon: -97.74316, + alt: 600.6, + heading: 15.4, + pitch: -30.5, + roll: 359.6, + }), + ); + assert.equal(state.lat, 30.2672); + assert.equal(state.lon, -97.7432); + assert.equal(state.alt, 601); + assert.equal(state.heading, 15); + assert.equal(state.pitch, -30); + assert.equal(state.roll, 0, '359.6 rounds to 360, which is 0'); + // Angles wrap into [0, 360) and are stored as the nearer signed turn. + assert.equal(decodeOk(encodeShareView({ heading: -10 })).state.heading, 350); + assert.equal(payloadBytes(encodeShareView({ heading: 350 })).length, 3); + // Out-of-range input is clamped or wrapped, never rejected. + const wild = decodeOk( + encodeShareView({ lat: 95, lon: 190, pitch: -120 }), + ).state; + assert.deepEqual([wild.lat, wild.lon, wild.pitch], [90, -170, -90]); +}); + +test('defaults travel as frozen meanings that match today’s app defaults', () => { + const defaults = createDefaultShareViewState(); + // When an app default moves, these tests should be updated to document the + // drift; the codec's defaults table must NOT change (see SHARE-LINK-FORMAT). + assert.equal(defaults.bloomIntensity, BLOOM_INTENSITY_DEFAULT); + assert.equal(defaults.bloomVersion, BLOOM_SCALE_VERSION); + assert.equal(defaults.hudVariant, HUD_LAYOUTS[0]); + assert.deepEqual(SHARE_VIEW_ENUMS.hudVariant.slice(0, HUD_LAYOUTS.length), [ + ...HUD_LAYOUTS, + ]); + assert.deepEqual(SHARE_VIEW_ENUMS.style, [...VIEW_STYLES]); + assert.deepEqual(SHARE_VIEW_ENUMS.mapStack, [...VIEW_MAPS]); + assert.deepEqual(SHARE_VIEW_ENUMS.detectionAllocation, [ + ...ALLOCATION_STRATEGIES, + ]); + for (const kind of VIEW_FOLLOW_KINDS) + assert.ok( + SHARE_VIEW_ENTITY_KINDS.some((entry) => entry.kind === kind), + kind, + ); +}); + +test('the field registry is well formed and permanent', () => { + const numbers = SHARE_VIEW_FIELDS.map((f) => f.number); + assert.equal(new Set(numbers).size, numbers.length); + assert.equal( + new Set(SHARE_VIEW_FIELDS.map((f) => f.key)).size, + numbers.length, + ); + // Snapshot: changing a number, key or default breaks links in the wild. + assert.deepEqual( + SHARE_VIEW_FIELDS.map((f) => [f.number, f.key, f.default]), + [ + [1, 'view', 'place'], + [2, 'lat', 0], + [3, 'lon', 0], + [4, 'alt', 800], + [5, 'heading', 0], + [6, 'pitch', -35], + [7, 'roll', 0], + [8, 'sharedAtMs', null], + [9, 'flags', 0], + [10, 'layerState', null], + [11, 'layerOptions', null], + [12, 'panelState', null], + [13, 'thing', null], + [14, 'style', 'normal'], + [15, 'mapStack', 'photoreal'], + [16, 'hudVariant', BY_STYLE], + [17, 'bloomIntensity', BY_STYLE], + [18, 'bloomVersion', 2], + [19, 'sharpenIntensity', BY_STYLE], + [20, 'detectionMode', BY_STYLE], + [21, 'detectionDensity', BY_STYLE], + [22, 'detectionAllocation', BY_STYLE], + [23, 'detectionFadePct', BY_STYLE], + [24, 'detectionOutsideOpacityPct', BY_STYLE], + [25, 'scopeFeatherPct', 100], + [26, 'scopeTerminusPct', null], + [27, 'styleParams', BY_STYLE], + ], + ); + assert.deepEqual( + SHARE_VIEW_FLAGS.map((f) => [f.bit, f.key, f.default]), + [ + [0, 'bloom', BY_STYLE], + [1, 'sharpen', BY_STYLE], + [2, 'hudVisible', BY_STYLE], + [3, 'celestialRing', BY_STYLE], + [4, 'scopeEnabled', true], + ], + ); + // The style-applied defaults are frozen meanings too. If the app's style + // tables move and this fails, freeze these values in the codec instead of + // updating the snapshot: links already shared depend on them. + const common = { + bloom: false, + bloomIntensity: 0, + sharpen: true, + sharpenIntensity: 49, + hudVariant: 'tactical', + hudVisible: true, + detectionMode: 'DENSE', + detectionDensity: 75, + detectionAllocation: 'ELASTIC', + detectionFadePct: 7, + detectionOutsideOpacityPct: 1, + celestialRing: false, + }; + assert.deepEqual(JSON.parse(JSON.stringify(SHARE_VIEW_STYLE_DEFAULTS)), { + normal: { ...common, styleParams: null }, + retro: { + ...common, + styleParams: { pixelation: 1, distortion: 0, instability: 0.42 }, + }, + surveillance: { + ...common, + styleParams: { + gain: 0.18, + bloom: 0.22, + scanlineStr: 0.96, + pixelation: 1, + }, + }, + thermal: { + ...common, + styleParams: { + sensitivity: 0.85, + bloom: 0.2, + mode: 0.33, + pixelation: 1, + palette: 0, + }, + }, + anime: { ...common, styleParams: { saturation: 1, edgeThick: 0.5 } }, + noir: { + ...common, + styleParams: { contrastAmt: 1.2, grainAmt: 0.5, vignetteAmt: 0.5 }, + }, + snow: { ...common, styleParams: { density: 0.6, wind: 0.5 } }, + }); + assert.deepEqual( + SHARE_VIEW_ENTITY_KINDS.map((e) => [e.code, e.kind]), + [ + [1, 'aircraft'], + [2, 'military_aircraft'], + [3, 'vessel'], + [4, 'satellite'], + ], + ); + assert.deepEqual( + SHARE_VIEW_PANELS.map((p) => [p.number, p.id]), + SHARE_VIEW_PANELS.map((p, i) => [i, p.id]), + ); +}); + +test('the panel table agrees with the long form’s panel tokens', () => { + for (const panel of SHARE_VIEW_PANELS) { + const decoded = decodePanelStateParams( + new URLSearchParams(`v=2&ui=${panel.token}.c.1_${panel.token}.p.1`), + ); + assert.deepEqual(decoded.specs, [ + { id: panel.id, collapsed: true, pinned: panel.pinnable ? true : null }, + ]); + } +}); + +test('the style parameter table agrees with the long form’s `sp` codec', () => { + for (const [style, specs] of Object.entries(SHARE_VIEW_STYLE_PARAMS)) { + const values = Object.fromEntries( + specs.map((s, i) => [s.key, s.min + ((s.max - s.min) * (i + 1)) / 7]), + ); + const params = new URLSearchParams('v=2'); + encodeStyleParamState(params, style, values); + const legacy = decodeStyleParamState(params, style); + assert.deepEqual( + normalizeShareViewState({ style, styleParams: values }).styleParams, + legacy, + style, + ); + assert.deepEqual( + decodeOk(encodeShareView({ style, styleParams: values })).state + .styleParams, + legacy, + style, + ); + } +}); + +test('every layer option has a frozen absent meaning', () => { + for (const entry of LAYER_STATE_REGISTRY) { + if (!entry.optionOwner) continue; + for (const spec of optionSpecs(entry.optionOwner)) { + assert.ok( + Object.hasOwn( + SHARE_VIEW_LAYER_OPTION_ABSENT[entry.optionOwner] || {}, + spec.key, + ), + `${entry.optionOwner}.${spec.key} needs an entry in SHARE_VIEW_LAYER_OPTION_ABSENT`, + ); + } + } + // A default-only layer state writes no option bytes at all. + const layerState = createDefaultLayerState(); + layerState.enabledLayerIds = [...REGISTERED_LAYER_IDS]; + const bytes = payloadBytes(encodeShareView({ layerState })); + assert.equal(bytes.includes(0x5a), false, 'no layer-options field'); +}); + +test('layer tokens map to permanent numbers covering the whole ledger', () => { + for (const token of Object.values(LAYER_STATE_TOKEN_RESERVATIONS)) { + assert.equal(layerTokenFromNumber(layerTokenNumber(token)), token); + } + for (const token of ['0', '9', 'a', 'z', '00', '0z', 'zz']) + assert.equal(layerTokenFromNumber(layerTokenNumber(token)), token); + assert.equal(layerTokenNumber('00'), 36); + assert.equal(layerTokenFromNumber(36 + 36 * 36), null); + const numbers = LAYER_STATE_REGISTRY.map((e) => layerTokenNumber(e.token)); + assert.equal(new Set(numbers).size, numbers.length); +}); + +test('layer sets: absent, explicitly empty and populated stay distinct', () => { + assert.equal( + decodeOk(encodeShareView({ layerState: null })).state.layerState, + null, + ); + const empty = decodeOk( + encodeShareView({ layerState: createDefaultLayerState() }), + ).state.layerState; + assert.deepEqual(empty, createDefaultLayerState()); + const all = createDefaultLayerState(); + all.enabledLayerIds = [...REGISTERED_LAYER_IDS]; + assertRoundTrip({ layerState: all }); +}); + +test('panels: only differences from the frozen layout are written', () => { + const defaults = { + specs: SHARE_VIEW_PANELS.map((p) => ({ + id: p.id, + collapsed: p.collapsed, + pinned: p.pinnable ? false : null, + })), + }; + const base = payloadBytes(encodeShareView({})).length; + assert.equal( + payloadBytes(assertRoundTrip({ panelState: defaults })).length, + base + 2, + ); + const oneChanged = structuredClone(defaults); + oneChanged.specs[3].collapsed = false; + assert.equal( + payloadBytes(assertRoundTrip({ panelState: oneChanged })).length, + base + 3, + ); + // A partial panel list (an older link) keeps its gaps as "no opinion". + const partial = { + specs: [{ id: 'cctv-panel', collapsed: false, pinned: null }], + }; + assert.deepEqual( + decodeOk(encodeShareView({ panelState: partial })).state.panelState, + partial, + ); +}); + +test('a place view opens at its camera; a thing view also names what to follow', () => { + const place = decodeOk(encodeShareView({ lat: 1, lon: 2 })).state; + assert.deepEqual(place.view, { kind: 'place' }); + for (const entry of SHARE_VIEW_ENTITY_KINDS) { + const id = entry.idForm === 'hex24' ? '3c6444' : '25544'; + const { state } = decodeOk( + encodeShareView({ + lat: 1, + lon: 2, + view: { kind: 'thing', entity: { kind: entry.kind, id } }, + }), + ); + assert.deepEqual(state.view, { + kind: 'thing', + entity: { kind: entry.kind, id }, + offset: { ...SHARE_VIEW_FOLLOW_OFFSET_DEFAULT }, + lastKnown: null, + }); + // Without a last-known fix the view keeps its stored camera. + assert.equal(state.lat, 1); + assert.equal(state.lon, 2); + } +}); + +test('thing ids use the compact numeric form when they can, text otherwise', () => { + const size = (kind, id) => + payloadBytes( + encodeShareView({ view: { kind: 'thing', entity: { kind, id } } }), + ).length; + assert.equal(size('aircraft', 'ffffff'), size('aircraft', '000001') + 3); + assert.ok(size('aircraft', '~abc123') > size('aircraft', 'abc123')); + assert.ok( + size('vessel', '366999712') < size('vessel', '002442000'), + 'leading zeros keep the text form', + ); + for (const [kind, id] of [ + ['aircraft', '000000'], + ['vessel', '002442000'], + ['military_aircraft', 'adsb_x-1'], + ['satellite', '1'], + ]) + assert.equal( + decodeOk( + encodeShareView({ view: { kind: 'thing', entity: { kind, id } } }), + ).state.view.entity.id, + id, + ); + // Invalid ids and kinds normalize to a place view. + for (const entity of [ + { kind: 'aircraft', id: 'NOT VALID' }, + { kind: 'satellite', id: 'abc' }, + { kind: 'ufo', id: '1' }, + ]) + assert.deepEqual( + normalizeShareViewState({ view: { kind: 'thing', entity } }).view, + { kind: 'place' }, + ); + assert.equal( + normalizeShareViewState({ + view: { kind: 'thing', entity: { kind: 'aircraft', id: 'ABC123' } }, + }).view.entity.id, + 'abc123', + ); +}); + +test('last-known time is stored relative to the created-at time when there is one', () => { + const lastKnown = { + lat: 10.0012, + lon: 20.0034, + alt: 11000, + observedAtMs: NOW_MS - 15_000, + }; + const view = { + kind: 'thing', + entity: { kind: 'aircraft', id: 'a1b2c3' }, + lastKnown, + }; + const withCreated = assertRoundTrip({ + lat: 10, + lon: 20, + sharedAtMs: NOW_MS, + view, + }); + const withoutCreated = assertRoundTrip({ lat: 10, lon: 20, view }); + assert.deepEqual(decodeOk(withoutCreated).state.view.lastKnown, lastKnown); + // Relative age (15 s) is 1 byte; the absolute time is 4. + assert.ok( + payloadBytes(withCreated).length - 5 < payloadBytes(withoutCreated).length, + ); +}); + +test('tracking selections in layer options become a thing view', () => { + const layerState = createDefaultLayerState(); + layerState.enabledLayerIds = ['military']; + layerState.options.flights.selectedMilitaryTrackingId = 'AE1234'; + const state = normalizeShareViewState({ layerState }); + assert.deepEqual(state.view, { + kind: 'thing', + entity: { kind: 'military_aircraft', id: 'ae1234' }, + offset: { ...SHARE_VIEW_FOLLOW_OFFSET_DEFAULT }, + lastKnown: null, + }); + assert.equal( + state.layerState.options.flights.selectedMilitaryTrackingId, + null, + ); + assertRoundTrip({ layerState }); +}); + +test('a created-at time in the future is dropped, like the long form’s `at`', () => { + const hash = encodeShareView({ sharedAtMs: NOW_MS + 60_000 }); + assert.equal(decode(hash).state.sharedAtMs, null); + assert.equal( + decodeShareView(hash, { nowMs: NOW_MS + 60_000 }).state.sharedAtMs, + NOW_MS + 60_000, + ); + // Times on either side of the format epoch survive. + for (const ms of [1000, (SHARE_VIEW_EPOCH_S - 86400) * 1000, NOW_MS]) + assert.equal( + decode(encodeShareView({ sharedAtMs: ms })).state.sharedAtMs, + ms, + ); +}); + +test('a code is the bare base64url payload; URL forms are not codes', () => { + const code = encodeShareView({ lat: 51.5, lon: -0.12 }); + assert.match(code, /^[A-Za-z0-9_-]+$/); + for (const form of [`#${code}`, `s=${code}`, `/share/${code}`]) + assert.equal(decode(form).ok, false, form); +}); + +/* ---------------- forward compatibility ---------------- */ + +/** Append raw fields to an encoded hash, as a newer encoder would. */ +function withExtraFields(hash, write) { + const writer = new WireWriter(); + writer.bytes.push(...payloadBytes(hash)); + write(writer); + return hashOf(writer.finish()); +} + +test('unknown top-level fields of every wire type are skipped and reported', () => { + const state = { + lat: 48.8584, + lon: 2.2945, + style: 'noir', + layerState: { enabledLayerIds: ['flights'] }, + }; + const hash = encodeShareView(state); + const future = withExtraFields(hash, (w) => { + w.uintField(40, 123456); + w.bytesField(41, [9, 9, 9, 0xff, 0]); + w.tag(42, WIRE_FIXED32).bytes.push(1, 2, 3, 4); + w.tag(43, WIRE_FIXED64).bytes.push(1, 2, 3, 4, 5, 6, 7, 8); + w.sintField(2000, -5); + }); + const result = decodeOk(future); + assert.deepEqual(result.state, decodeOk(hash).state); + assert.deepEqual( + result.unknownFields.map((u) => [u.scope, u.field, u.wireType]), + [ + ['view', 40, 0], + ['view', 41, 2], + ['view', 42, 5], + ['view', 43, 1], + ['view', 2000, 0], + ], + ); +}); + +test('unknown fields inside a thing view, codes and bits are skipped too', () => { + const thing = new WireWriter(); + thing.uintField(1, 0xa1b2c3).uintField(5, 900); + thing.textField(20, 'cockpit'); // a future thing field + const writer = new WireWriter(); + writer.bytes.push(1); + writer.sintField(2, 100000).uintField(9, 0b100001); // flag bit 5 unknown + writer.bytesField( + 10, + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], + ); // layer 160 + writer.bytesField(12, new WireWriter().varint(11 * 16 + 4 + 1).finish()); // panel 11 + writer.uintField(14, 99); // style code 99 + writer.bytesField(13, thing.finish()); + const result = decodeOk(hashOf(writer.finish())); + assert.deepEqual(result.state.view, { + kind: 'thing', + entity: { kind: 'aircraft', id: 'a1b2c3' }, + offset: { ...SHARE_VIEW_FOLLOW_OFFSET_DEFAULT, range: 900 }, + lastKnown: null, + }); + assert.equal(result.state.bloom, true); + assert.equal(result.state.style, 'normal'); + // The followed entity's layer is implied. + assert.deepEqual(result.state.layerState.enabledLayerIds, ['flights']); + assert.deepEqual( + result.unknownFields.map((u) => [u.scope, u.field ?? u.code]), + [ + ['enum:style', 99], + ['flags', 32], + ['layer', 160], + ['panel', 11], + ['thing', 20], + ], + ); +}); + +test('an unknown view kind falls back to the stored camera', () => { + const camera = { + lat: 35.6762, + lon: 139.6503, + alt: 2500, + heading: 45, + pitch: -20, + }; + const base = payloadBytes(encodeShareView(camera)); + const expected = decodeOk(encodeShareView(camera)).state; + + const futureKind = new WireWriter(); + futureKind.bytes.push(...base); + futureKind.uintField(1, 7); + futureKind.bytesField(60, [1, 2, 3]); + const kindResult = decodeOk(hashOf(futureKind.finish())); + assert.deepEqual(kindResult.state, expected); + assert.equal(kindResult.fallback, 'unknown-view-kind'); + + const missing = new WireWriter(); + missing.bytes.push(...base); + missing.uintField(1, 1); + assert.equal(decodeOk(hashOf(missing.finish())).fallback, 'missing-thing'); +}); + +test('an unknown or invalid entity falls back to the camera its fix implies', () => { + const fix = { lat: 35.6762, lon: 139.6503, alt: 900, observedAtMs: null }; + const offset = { range: 3000, heading: 90, pitch: -40 }; + const thingOf = (write) => { + const thing = new WireWriter(); + write(thing); + thing + .uintField(5, offset.range) + .sintField(6, offset.heading) + .sintField(7, offset.pitch) + .sintField(8, Math.round(fix.lat * 1e4)) + .sintField(9, Math.round(fix.lon * 1e4)) + .sintField(10, fix.alt); + const writer = new WireWriter(); + writer.bytes.push(1); + writer.bytesField(13, thing.finish()); + return decodeOk(hashOf(writer.finish())); + }; + const expected = normalizeShareViewState(followCameraFor(fix, offset)); + + // A newer app's entity kind: its id field is unknown here. + const future = thingOf((thing) => thing.textField(30, 'cam-77')); + assert.equal(future.fallback, 'unknown-entity-kind'); + assert.deepEqual(future.state, expected); + assert.deepEqual(future.unknownFields, [ + { scope: 'thing', field: 30, wireType: 2 }, + ]); + + const none = thingOf(() => {}); + assert.equal(none.fallback, 'missing-entity-kind'); + assert.deepEqual(none.state, expected); + + const badId = thingOf((thing) => thing.uintField(1, 0x1000000)); + assert.equal(badId.fallback, 'invalid-entity-id'); + assert.deepEqual(badId.state, expected); +}); + +test('enum names outside the code tables travel as text', () => { + const hash = encodeShareView({ + hudVariant: 'future-hud', + mapStack: 'future-map', + }); + const { state, unknownFields } = decodeOk(hash); + assert.equal(state.hudVariant, 'future-hud'); + assert.equal(state.mapStack, 'future-map'); + assert.equal(unknownFields, undefined); + const style = new WireWriter(); + style.bytes.push(1); + style.textField(14, 'thermal'); + assert.equal(decodeOk(hashOf(style.finish())).state.style, 'thermal'); + const unknownStyle = new WireWriter(); + unknownStyle.bytes.push(1); + unknownStyle.textField(14, 'sepia'); + const result = decodeOk(hashOf(unknownStyle.finish())); + assert.equal(result.state.style, 'normal'); + assert.deepEqual(result.unknownFields, [ + { scope: 'enum:style', code: 'sepia' }, + ]); +}); + +/* ---------------- integrity ---------------- */ + +test('malformed input is rejected with a reason, never thrown', () => { + const good = payloadBytes( + encodeShareView({ + lat: 10, + lon: 20, + layerState: { enabledLayerIds: ['flights'] }, + }), + ); + const cases = { + 'not-a-string': [null, undefined, 42, {}], + 'too-long': ['A'.repeat(MAX_SHARE_VIEW_CHARS + 1)], + 'bad-base64': ['AQ==', 'A*Q', 'AQAAA', 's=AQc', '#AQc'], + empty: [''], + // A lone byte has no room for a check byte. + truncated: ['AQ'], + checksum: [ + encodeBase64Url([1, crc8([1]) ^ 1]), + encodeBase64Url([...good, (crc8(good) + 1) % 256]), + ], + 'unsupported-version': [hashOf([0]), hashOf([2, 8, 1])], + 'truncated ': [ + hashOf(good.slice(0, good.length - 1)), + hashOf([1, 0x10]), // field 2 varint with no value + hashOf([1, 0x52, 0x7f, 1]), // field 10 claims 127 bytes + hashOf([1, 0x52, 0xff, 0xff, 0xff, 0x7f]), // absurd length + ], + 'varint-overflow': [ + hashOf([1, 0x10, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01]), + ], + 'non-canonical-varint': [hashOf([1, 0x10, 0x82, 0x00])], + 'bad-wire-type': [hashOf([1, 0x13]), hashOf([1, 0x16])], + 'bad-field-number': [hashOf([1, 0x00])], + 'wire-type-mismatch': [ + hashOf([1, 0x12, 0x01, 0x00]), + hashOf([1, 0x50, 0x01]), + ], + 'duplicate-field': [hashOf([1, 0x10, 0x02, 0x10, 0x04])], + 'duplicate-entry': [hashOf([1, 0x62, 0x02, 0x31, 0x31])], + 'out-of-range': [ + hashOf([1, ...new WireWriter().sintField(2, 900001).finish()]), + ], + 'bad-text': [ + hashOf([1, ...new WireWriter().bytesField(14, [0xc3, 0xa9]).finish()]), + ], + }; + for (const [label, inputs] of Object.entries(cases)) { + const reason = label.trim(); + for (const input of inputs) { + const result = decodeShareView(input, { nowMs: NOW_MS }); + assert.deepEqual( + result, + { ok: false, reason }, + `${reason}: ${String(input).slice(0, 40)}`, + ); + } + } +}); + +test('every truncation and single-byte corruption of real payloads is handled', () => { + const random = rng(7); + for (let i = 0; i < 60; i += 1) { + const bytes = payloadBytes(encodeShareView(randomState(random))); + for (let cut = 1; cut < bytes.length; cut += 1) { + const result = decode(hashOf(bytes.slice(0, cut))); + assert.equal(typeof result.ok, 'boolean'); + } + for (let j = 0; j < 40; j += 1) { + const corrupt = Uint8Array.from(bytes); + corrupt[1 + Math.floor(random() * (corrupt.length - 1))] = Math.floor( + random() * 256, + ); + const result = decode(hashOf(corrupt)); + assert.equal(typeof result.ok, 'boolean'); + if (result.ok) + assert.deepEqual(normalizeShareViewState(result.state), result.state); + } + } +}); + +/* ---------------- sizes ---------------- */ + +test('size: a camera-only place view', () => { + const hash = assertRoundTrip({ + lat: 48.8584, + lon: 2.2945, + alt: 1200, + heading: 120, + pitch: -25, + }); + assert.equal(hash.length, 24); + const copied = assertRoundTrip({ + lat: 48.8584, + lon: 2.2945, + alt: 1200, + heading: 120, + pitch: -25, + sharedAtMs: NOW_MS, + }); + assert.equal(copied.length, 31); +}); + +test('size: following an aircraft', () => { + const layerState = createDefaultLayerState(); + layerState.enabledLayerIds = ['flights']; + layerState.options.flights.models3d = true; + const view = (offset) => ({ + kind: 'thing', + entity: { kind: 'aircraft', id: 'a1b2c3' }, + offset, + lastKnown: { + lat: 37.6213, + lon: -122.379, + alt: 3200, + observedAtMs: NOW_MS - 4000, + }, + }); + // The app's own follow camera looks north at −35°; only the range is new. + const followed = assertRoundTrip({ + view: view({ range: 5800, heading: 0, pitch: -35 }), + layerState, + sharedAtMs: NOW_MS, + }); + assert.equal(followed.length, 44); + // Made by the share button: the fix is as of the link, so it has no age. + const fresh = view({ range: 5800, heading: 0, pitch: -35 }); + fresh.lastKnown.observedAtMs = NOW_MS; + assert.equal( + assertRoundTrip({ view: fresh, layerState, sharedAtMs: NOW_MS }).length, + 42, + ); + // In Noir with Noir's own look, only the style is added. + assert.equal( + assertRoundTrip({ + ...SHARE_VIEW_STYLE_DEFAULTS.noir, + style: 'noir', + view: fresh, + layerState, + sharedAtMs: NOW_MS, + }).length, + 44, + ); + // After orbiting the aircraft, heading and pitch are written too. + const orbited = assertRoundTrip({ + view: view({ range: 1500, heading: 30, pitch: -20 }), + layerState, + sharedAtMs: NOW_MS, + }); + assert.equal(orbited.length, 50); + // No camera is stored: it is where following the fix puts it. + const { state } = decodeOk(orbited); + assert.deepEqual( + { + lat: state.lat, + lon: state.lon, + alt: state.alt, + heading: state.heading, + pitch: state.pitch, + }, + { lat: 37.6103, lon: -122.387, alt: 3713, heading: 30, pitch: -20 }, + ); +}); + +test('the follow offset converts to and from east/north/up metres', () => { + for (const offset of [ + { range: 5800, heading: 0, pitch: -35 }, + { range: 1500, heading: 30, pitch: -20 }, + { range: 750000, heading: 225, pitch: -60 }, + { range: 300, heading: 359, pitch: 10 }, + ]) + assert.deepEqual(followOffsetFromEnu(followOffsetToEnu(offset)), offset); + const { east, north, up } = followOffsetToEnu({ + range: 1000, + heading: 90, + pitch: -30, + }); + // Looking east and down: the camera is west of the entity and above it. + assert.ok(east < -800 && Math.abs(north) < 1e-9 && Math.abs(up - 500) < 1e-9); + assert.equal(followOffsetFromEnu({ east: 0, north: 0, up: 0 }), null); +}); + +test('the derived camera looks at the fix from the offset, even from orbit', () => { + const R = 6_371_000; + const ecef = ({ lat, lon, alt }) => { + const r = R + alt; + const c = Math.cos((lat * Math.PI) / 180); + return [ + r * c * Math.cos((lon * Math.PI) / 180), + r * c * Math.sin((lon * Math.PI) / 180), + r * Math.sin((lat * Math.PI) / 180), + ]; + }; + for (const [fix, offset] of [ + // A satellite followed from the app's own distance. + [ + { lat: 51.6, lon: -0.1, alt: 420_000 }, + { range: 750_000, heading: 225, pitch: -35 }, + ], + // Near a pole, and across the antimeridian. + [ + { lat: 89.9, lon: 179.95, alt: 10_000 }, + { range: 20_000, heading: 90, pitch: -20 }, + ], + [ + { lat: 37.6213, lon: -122.379, alt: 3200 }, + { range: 5800, heading: 0, pitch: -35 }, + ], + ]) { + const camera = followCameraFor(fix, offset); + const [a, b] = [ecef(camera), ecef(fix)]; + const distance = Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); + assert.ok(Math.abs(distance - offset.range) < 1e-3 * offset.range); + assert.ok(Math.abs(camera.lon) <= 180 && Math.abs(camera.lat) <= 90); + // Short ranges look along the offset itself. + if (offset.range < 10_000) { + assert.ok(Math.abs(camera.heading - offset.heading) < 0.1); + assert.ok(Math.abs(camera.pitch - offset.pitch) < 0.1); + } + } +}); + +test('a fix without its own time is as of the link', () => { + const view = { + kind: 'thing', + entity: { kind: 'satellite', id: '25544' }, + lastKnown: { lat: 1, lon: 2, alt: 420000 }, + }; + const state = normalizeShareViewState({ view, sharedAtMs: NOW_MS }); + assert.equal(state.view.lastKnown.observedAtMs, NOW_MS); + assertRoundTrip({ view, sharedAtMs: NOW_MS }); + // With no created-at time either, the fix has no time. + assert.equal( + decodeOk(encodeShareView({ view })).state.view.lastKnown.observedAtMs, + null, + ); +}); + +test('the check byte rejects every single-character typo', () => { + const ALPHABET = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + const random = rng(0xc0de); + let typos = 0; + for (let i = 0; i < 40; i += 1) { + const code = encodeShareView(randomState(random)); + for (let at = 0; at < code.length; at += 1) { + for (const char of ALPHABET) { + if (char === code[at]) continue; + const typo = code.slice(0, at) + char + code.slice(at + 1); + const result = decode(typo); + assert.equal(result.ok, false, `${code} → ${typo}`); + typos += 1; + } + } + } + assert.ok(typos > 50_000); +}); + +/* ---------------- review regressions ---------------- */ + +test('unknown detection names fall back to their defaults and are reported', () => { + const writer = new WireWriter(); + writer.bytes.push(1); + writer.textField(20, 'FUTURE').textField(22, 'GREEDY'); + const result = decodeOk(hashOf(writer.finish())); + assert.equal(result.state.detectionMode, 'DENSE'); + assert.equal(result.state.detectionAllocation, 'ELASTIC'); + assert.deepEqual(result.unknownFields, [ + { scope: 'enum:detectionMode', code: 'FUTURE' }, + { scope: 'enum:detectionAllocation', code: 'GREEDY' }, + ]); +}); + +test('extreme and negative-zero numbers normalize to values the decoder returns', () => { + assert.ok(Number.isFinite(normalizeShareViewState({ lon: 1e305 }).lon)); + assertRoundTrip({ lon: 1e305, lat: -1e305 }); + for (const state of [ + { lat: -0.00001 }, + { lon: -0.00001 }, + { alt: -0.2 }, + { pitch: -0.2 }, + ]) { + assertRoundTrip(state); + for (const value of Object.values(normalizeShareViewState(state))) + assert.equal(Object.is(value, -0), false); + } +}); + +test('future panel values and style tokens are skipped, not applied', () => { + const panels = new WireWriter(); + panels.bytes.push(1); + panels.bytesField( + 12, + new WireWriter() + .varint(3 * 16 + 3 * 4) + .varint(0 * 16 + 2 * 4 + 3) + .finish(), + ); + const panelResult = decodeOk(hashOf(panels.finish())); + const ids = panelResult.state.panelState.specs.map((spec) => spec.id); + assert.equal(ids.includes('cctv-panel'), false); + assert.equal(ids.includes('control-panel'), false); + assert.equal(panelResult.unknownFields.length, 2); + + const style = new WireWriter(); + style.bytes.push(1); + style.uintField(14, 3); // thermal + style.bytesField( + 27, + new WireWriter().varint(36).varint(5).varint(37).varint(6).finish(), + ); + const styleResult = decodeOk(hashOf(style.finish())); + // Unknown tokens change nothing: the style keeps its own parameters. + assert.deepEqual( + styleResult.state.styleParams, + SHARE_VIEW_STYLE_DEFAULTS.thermal.styleParams, + ); + assert.equal(styleResult.unknownFields.length, 2); +}); + +test('a layer option filed under a mirror layer is ignored', () => { + const writer = new WireWriter(); + writer.bytes.push(1); + const military = layerTokenNumber('m'); + writer.bytesField(10, [0, 0, 0b01000000]); // m = 22 + writer.bytesField( + 11, + new WireWriter() + .varint(military) + .varint(layerTokenNumber('m') * 2) // option `m` (models3dMode) as integer + .varint(0) + .finish(), + ); + const result = decodeOk(hashOf(writer.finish())); + assert.deepEqual(result.state.layerState.enabledLayerIds, ['military']); + assert.deepEqual(result.unknownFields, [ + { scope: 'layer-option', code: `${military}.${layerTokenNumber('m')}` }, + ]); +}); diff --git a/src/share/wire.js b/src/share/wire.js new file mode 100644 index 000000000..f0b72f301 --- /dev/null +++ b/src/share/wire.js @@ -0,0 +1,259 @@ +/** + * Byte-level primitives for the compact share-view format: base64url text, + * protobuf-style varints, and a tag-length-value reader and writer. + * + * Pure: no DOM, no Cesium, no platform buffers. Every integrity failure is a + * {@link ShareWireError} with a stable `reason`, so the view codec can turn it + * into a rejected link instead of an exception at app start. + */ + +/** Wire types. Field numbers and their wire types are permanent. */ +export const WIRE_VARINT = 0; +export const WIRE_FIXED64 = 1; +export const WIRE_LEN = 2; +export const WIRE_FIXED32 = 5; + +/** Field numbers stay inside protobuf's range so tags fit in five bytes. */ +export const MAX_FIELD_NUMBER = 2 ** 29 - 1; +const MAX_VARINT_BYTES = 8; // 56 bits covers every safe integer. + +/** A malformed or out-of-bounds payload. `reason` is a stable code. */ +export class ShareWireError extends Error { + constructor(reason, detail = '') { + super(detail ? `${reason}: ${detail}` : reason); + this.name = 'ShareWireError'; + this.reason = reason; + } +} + +const BASE64URL = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; +const BASE64URL_INDEX = new Map( + [...BASE64URL].map((char, index) => [char, index]), +); + +/** Unpadded base64url text for `bytes`. */ +export function encodeBase64Url(bytes) { + let out = ''; + for (let i = 0; i < bytes.length; i += 3) { + const a = bytes[i]; + const b = i + 1 < bytes.length ? bytes[i + 1] : 0; + const c = i + 2 < bytes.length ? bytes[i + 2] : 0; + const triple = (a << 16) | (b << 8) | c; + out += BASE64URL[(triple >> 18) & 63] + BASE64URL[(triple >> 12) & 63]; + if (i + 1 < bytes.length) out += BASE64URL[(triple >> 6) & 63]; + if (i + 2 < bytes.length) out += BASE64URL[triple & 63]; + } + return out; +} + +/** + * Bytes for unpadded base64url text. Rejects padding, foreign characters, an + * impossible length and non-zero trailing bits, so every payload has exactly + * one spelling. + */ +export function decodeBase64Url(text) { + if (typeof text !== 'string' || text.length === 0) + throw new ShareWireError('empty'); + if (text.length % 4 === 1) throw new ShareWireError('bad-base64'); + const bytes = new Uint8Array(Math.floor((text.length * 3) / 4)); + let buffer = 0; + let bits = 0; + let out = 0; + for (const char of text) { + const value = BASE64URL_INDEX.get(char); + if (value === undefined) throw new ShareWireError('bad-base64'); + buffer = ((buffer << 6) | value) & 0xffffff; + bits += 6; + if (bits >= 8) { + bits -= 8; + bytes[out++] = (buffer >> bits) & 0xff; + } + } + if (bits > 0 && (buffer & ((1 << bits) - 1)) !== 0) + throw new ShareWireError('bad-base64', 'non-zero trailing bits'); + return bytes; +} + +/** + * CRC-8 (polynomial x⁸ + x² + x + 1, 0x07; initial value 0; no reflection; + * no final XOR) of `bytes`. A degree-8 CRC detects every error burst of up to + * 8 bits, so any one mistyped base64url character (6 bits) is caught. + */ +export function crc8(bytes) { + let crc = 0; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) + crc = crc & 0x80 ? ((crc << 1) ^ 0x07) & 0xff : (crc << 1) & 0xff; + } + return crc; +} + +/** Map a signed integer onto the unsigned varint space (0,-1,1,-2 → 0,1,2,3). */ +export function zigzag(value) { + return value >= 0 ? value * 2 : -value * 2 - 1; +} + +/** Inverse of {@link zigzag}. */ +export function unzigzag(value) { + return value % 2 === 0 ? value / 2 : -(value + 1) / 2; +} + +/** Largest magnitude a zigzag field can carry without losing precision. */ +export const MAX_ZIGZAG_MAGNITUDE = Math.floor(Number.MAX_SAFE_INTEGER / 2); + +/** Append-only byte sink with protobuf-style field helpers. */ +export class WireWriter { + constructor() { + this.bytes = []; + } + + varint(value) { + if (!Number.isSafeInteger(value) || value < 0) + throw new RangeError(`varint out of range: ${value}`); + let rest = value; + while (rest >= 0x80) { + this.bytes.push((rest % 0x80) | 0x80); + rest = Math.floor(rest / 0x80); + } + this.bytes.push(rest); + return this; + } + + tag(field, wireType) { + if (!Number.isInteger(field) || field < 1 || field > MAX_FIELD_NUMBER) + throw new RangeError(`field number out of range: ${field}`); + return this.varint(field * 8 + wireType); + } + + uintField(field, value) { + return this.tag(field, WIRE_VARINT).varint(value); + } + + sintField(field, value) { + if (!Number.isSafeInteger(value) || Math.abs(value) > MAX_ZIGZAG_MAGNITUDE) + throw new RangeError(`signed value out of range: ${value}`); + return this.tag(field, WIRE_VARINT).varint(zigzag(value)); + } + + bytesField(field, bytes) { + this.tag(field, WIRE_LEN).varint(bytes.length); + for (const byte of bytes) this.bytes.push(byte); + return this; + } + + /** ASCII-only text; every share-view string is drawn from ASCII grammars. */ + textField(field, text) { + return this.bytesField(field, asciiBytes(text)); + } + + finish() { + return Uint8Array.from(this.bytes); + } +} + +/** Printable-ASCII bytes for `text`; anything else is a programming error. */ +export function asciiBytes(text) { + const bytes = []; + for (let i = 0; i < text.length; i += 1) { + const code = text.charCodeAt(i); + if (code < 0x20 || code > 0x7e) + throw new RangeError('share-view text must be printable ASCII'); + bytes.push(code); + } + return bytes; +} + +/** Printable-ASCII text from bytes; anything else is a malformed payload. */ +export function asciiText(bytes) { + let out = ''; + for (const byte of bytes) { + if (byte < 0x20 || byte > 0x7e) throw new ShareWireError('bad-text'); + out += String.fromCharCode(byte); + } + return out; +} + +/** Bounds-checked cursor over one message (or one packed blob). */ +export class WireReader { + constructor(bytes, start = 0, end = bytes.length) { + this.bytes = bytes; + this.pos = start; + this.end = end; + } + + get done() { + return this.pos >= this.end; + } + + /** One canonical varint no larger than Number.MAX_SAFE_INTEGER. */ + varint() { + let value = 0; + let scale = 1; + for (let count = 0; count < MAX_VARINT_BYTES; count += 1) { + if (this.pos >= this.end) throw new ShareWireError('truncated'); + const byte = this.bytes[this.pos++]; + value += (byte & 0x7f) * scale; + if ((byte & 0x80) === 0) { + // A zero final byte after a continuation is an overlong spelling. + if (count > 0 && byte === 0) + throw new ShareWireError('non-canonical-varint'); + if (!Number.isSafeInteger(value)) + throw new ShareWireError('varint-overflow'); + return value; + } + scale *= 0x80; + } + throw new ShareWireError('varint-overflow'); + } + + svarint() { + return unzigzag(this.varint()); + } + + /** The next field's number and wire type, rejecting reserved wire types. */ + tag() { + const key = this.varint(); + const field = Math.floor(key / 8); + const wireType = key % 8; + if (field < 1 || field > MAX_FIELD_NUMBER) + throw new ShareWireError('bad-field-number'); + if ( + wireType !== WIRE_VARINT && + wireType !== WIRE_FIXED64 && + wireType !== WIRE_LEN && + wireType !== WIRE_FIXED32 + ) + throw new ShareWireError('bad-wire-type', String(wireType)); + return { field, wireType }; + } + + /** A length-delimited body as a sub-reader over the same bytes. */ + lengthDelimited() { + const length = this.varint(); + if (length > this.end - this.pos) + throw new ShareWireError('truncated', 'length exceeds payload'); + const reader = new WireReader(this.bytes, this.pos, this.pos + length); + this.pos += length; + return reader; + } + + /** Remaining bytes of this reader as a fresh array. */ + rest() { + const out = this.bytes.slice(this.pos, this.end); + this.pos = this.end; + return out; + } + + /** Skip one value of `wireType`, so newer fields cost older decoders nothing. */ + skip(wireType) { + if (wireType === WIRE_VARINT) this.varint(); + else if (wireType === WIRE_LEN) this.lengthDelimited(); + else { + const size = wireType === WIRE_FIXED64 ? 8 : 4; + if (size > this.end - this.pos) throw new ShareWireError('truncated'); + this.pos += size; + } + } +} diff --git a/src/share/wire.test.mjs b/src/share/wire.test.mjs new file mode 100644 index 000000000..5383293b3 --- /dev/null +++ b/src/share/wire.test.mjs @@ -0,0 +1,188 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + ShareWireError, + WIRE_FIXED32, + WIRE_FIXED64, + WIRE_LEN, + WIRE_VARINT, + WireReader, + WireWriter, + crc8, + decodeBase64Url, + encodeBase64Url, + unzigzag, + zigzag, +} from './wire.js'; + +const reasonOf = (fn) => { + try { + fn(); + } catch (error) { + assert.ok(error instanceof ShareWireError, String(error)); + return error.reason; + } + assert.fail('expected a ShareWireError'); +}; + +test('base64url round-trips every length and byte value without padding', () => { + for (let length = 0; length < 70; length += 1) { + const bytes = Uint8Array.from( + { length }, + (_, i) => (i * 97 + length) & 0xff, + ); + const text = encodeBase64Url(bytes); + assert.match(text, /^[A-Za-z0-9_-]*$/); + if (length) assert.deepEqual(decodeBase64Url(text), bytes); + } + const all = Uint8Array.from({ length: 256 }, (_, i) => i); + assert.deepEqual(decodeBase64Url(encodeBase64Url(all)), all); + assert.equal(encodeBase64Url(Uint8Array.of(0xfb, 0xff)), '-_8'); +}); + +test('base64url rejects padding, foreign characters, bad lengths and stray bits', () => { + for (const text of ['', 'AQ==', 'A+Q', 'A/Q', 'A Q', 'AQ%', 'A', 'AQAAA']) { + assert.ok( + ['empty', 'bad-base64'].includes(reasonOf(() => decodeBase64Url(text))), + text, + ); + } + // "AR" carries one byte (0x01) plus four trailing bits that must be zero. + assert.equal( + reasonOf(() => decodeBase64Url('AR')), + 'bad-base64', + ); + assert.deepEqual(decodeBase64Url('AQ'), Uint8Array.of(1)); +}); + +test('zigzag maps small magnitudes of either sign to small codes', () => { + assert.deepEqual([0, -1, 1, -2, 2].map(zigzag), [0, 1, 2, 3, 4]); + for (const value of [0, 1, -1, 63, -64, 1e9, -1e9, 2 ** 51, -(2 ** 51)]) { + assert.equal(unzigzag(zigzag(value)), value); + } +}); + +test('varints round-trip up to the largest safe integer', () => { + const values = [ + 0, + 1, + 127, + 128, + 300, + 2 ** 21, + 2 ** 31, + 2 ** 32 + 5, + Number.MAX_SAFE_INTEGER, + ]; + const writer = new WireWriter(); + for (const value of values) writer.varint(value); + const reader = new WireReader(writer.finish()); + for (const value of values) assert.equal(reader.varint(), value); + assert.ok(reader.done); + assert.deepEqual( + new WireWriter().varint(300).finish(), + Uint8Array.of(0xac, 0x02), + ); +}); + +test('the writer refuses values the format cannot carry', () => { + assert.throws(() => new WireWriter().varint(-1), RangeError); + assert.throws(() => new WireWriter().varint(1.5), RangeError); + assert.throws(() => new WireWriter().varint(2 ** 53), RangeError); + assert.throws(() => new WireWriter().tag(0, WIRE_VARINT), RangeError); + assert.throws(() => new WireWriter().textField(1, 'é'), RangeError); +}); + +test('the reader rejects truncated, overlong and overflowing varints', () => { + assert.equal( + reasonOf(() => new WireReader(Uint8Array.of(0x80)).varint()), + 'truncated', + ); + assert.equal( + reasonOf(() => new WireReader(Uint8Array.of(0x81, 0x00)).varint()), + 'non-canonical-varint', + ); + const tooLong = new Uint8Array(9).fill(0xff); + assert.equal( + reasonOf(() => new WireReader(tooLong).varint()), + 'varint-overflow', + ); + const beyondSafe = Uint8Array.of( + 0xff, + 0xff, + 0xff, + 0xff, + 0xff, + 0xff, + 0xff, + 0x7f, + ); + assert.equal( + reasonOf(() => new WireReader(beyondSafe).varint()), + 'varint-overflow', + ); +}); + +test('the reader bounds lengths and rejects group and reserved wire types', () => { + const absurd = new WireWriter().tag(3, WIRE_LEN).varint(1_000_000).finish(); + const reader = new WireReader(absurd); + reader.tag(); + assert.equal( + reasonOf(() => reader.lengthDelimited()), + 'truncated', + ); + for (const wireType of [3, 4, 6, 7]) { + const bytes = new WireWriter().varint(8 + wireType).finish(); + assert.equal( + reasonOf(() => new WireReader(bytes).tag()), + 'bad-wire-type', + ); + } + assert.equal( + reasonOf(() => new WireReader(Uint8Array.of(0x00)).tag()), + 'bad-field-number', + ); +}); + +test('skip steps over each wire type and stops at a truncated value', () => { + const writer = new WireWriter() + .uintField(1, 300) + .bytesField(2, [1, 2, 3]) + .tag(3, WIRE_FIXED32); + writer.bytes.push(1, 2, 3, 4); + writer.tag(4, WIRE_FIXED64); + writer.bytes.push(1, 2, 3, 4, 5, 6, 7, 8); + writer.uintField(5, 9); + const reader = new WireReader(writer.finish()); + for (let field = 1; field <= 4; field += 1) + reader.skip(reader.tag().wireType); + assert.deepEqual(reader.tag(), { field: 5, wireType: WIRE_VARINT }); + assert.equal(reader.varint(), 9); + const short = new WireWriter().tag(1, WIRE_FIXED64).finish(); + const shortReader = new WireReader(Uint8Array.of(...short, 1, 2)); + assert.equal( + reasonOf(() => shortReader.skip(shortReader.tag().wireType)), + 'truncated', + ); +}); + +test('crc8 is CRC-8/SMBUS and catches every burst of up to 8 bits', () => { + // The catalogued check value for the ASCII digits 1–9. + assert.equal(crc8(new TextEncoder().encode('123456789')), 0xf4); + assert.equal(crc8([]), 0); + const bytes = [1, 0x10, 0x82, 0x40, 0x7f, 0xff, 0x00, 0x33]; + const sum = crc8(bytes); + for (let bit = 0; bit + 8 <= bytes.length * 8; bit += 1) { + for (let pattern = 1; pattern < 256; pattern += 1) { + // Flip a burst starting at `bit` (its first bit always set). + if (!(pattern & 1)) continue; + const flipped = [...bytes]; + for (let k = 0; k < 8; k += 1) { + if (!(pattern & (1 << k))) continue; + const at = bit + k; + flipped[at >> 3] ^= 0x80 >> (at & 7); + } + assert.notEqual(crc8(flipped), sum, `burst ${pattern} at bit ${bit}`); + } + } +}); diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs index 746d5b3bc..91961e0b1 100644 --- a/src/sharelink.celestial.test.mjs +++ b/src/sharelink.celestial.test.mjs @@ -8,6 +8,12 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { ShareLinkManager, decodeShareCreatedAtMs } from './sharelink.js'; import { createDefaultLayerState } from './data/layerState.js'; +import { parseSharePath } from './share/shareLinkPath.js'; +import { decodeShareView } from './share/viewCodec.js'; + +/** The decoded view of a copied `/share/` link. */ +const sharedState = (href) => + decodeShareView(parseSharePath(new URL(href).pathname).code).state; const uiSource = readShellSource(); @@ -310,8 +316,8 @@ test('copy adds a fresh ephemeral timestamp without aging the live URL', async ( assert.equal(await manager.copyLink({ nowMs: 2_000_000 }), true); assert.equal(await manager.copyLink({ nowMs: 2_002_000 }), true); - assert.equal(new URL(copied[0]).hash.includes('at=2000'), true); - assert.equal(new URL(copied[1]).hash.includes('at=2002'), true); + assert.equal(sharedState(copied[0]).sharedAtMs, 2_000_000); + assert.equal(sharedState(copied[1]).sharedAtMs, 2_002_000); assert.equal(window.location.hash, liveHash); assert.equal(new URLSearchParams(window.location.hash.slice(1)).has('at'), false); }); @@ -323,8 +329,7 @@ test('copy snapshots current state while incoming hash writes are still suppress manager.parseInitialHash(); assert.equal(manager._initialRestorePending, true); assert.equal(await manager.copyLink({ nowMs: 3_000_000 }), true); - const params = new URL(copied).hash.slice(1); - assert.equal(new URLSearchParams(params).get('at'), '3000'); + assert.equal(sharedState(copied).sharedAtMs, 3_000_000); assert.equal(window.location.hash, '#v=2&lat=10&lon=20&l=e&at=100'); }); diff --git a/src/sharelink.js b/src/sharelink.js index 9668fa979..4a0291a84 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -11,12 +11,30 @@ import { encodeLayerStateParams, } from './data/layerState.js'; import { STYLE_URL_NAMES } from './view/index.js'; +import { + SHARE_VIEW_PANELS, + SHARE_VIEW_STYLE_PARAMS, + decodeShareView, + encodeShareView, +} from './share/viewCodec.js'; +import { + fromLegacyShareState, + toLegacyShareState, +} from './share/legacyShareState.js'; +import { + appRootUrl, + parseSharePath, + shareLinkUrl, +} from './share/shareLinkPath.js'; /** * Share Links — URL Hash State Management * - * Encodes camera position + style into the URL hash so links can be shared. - * Format: #lat=37.77&lon=-122.42&alt=800&heading=0&pitch=-35&style=nvg&bloom=1&bi=84&bv=2&sharpen=0&si=65&hud=tactical&hv=1&dm=BALANCED&dd=50&da=elastic&kf=16&ko=0&cr=0&map=photoreal + * The address bar follows the live view in the long hash form: + * #lat=37.77&lon=-122.42&alt=800&heading=0&pitch=-35&style=nvg&bloom=1&bi=84&bv=2&sharpen=0&si=65&hud=tactical&hv=1&dm=BALANCED&dd=50&da=elastic&kf=16&ko=0&cr=0&map=photoreal + * + * A shared link is the short form, `/share/`, with the code from + * src/share/viewCodec.js (docs/SHARE-LINK-FORMAT.md). Both forms open here. */ const DEBOUNCE_MS = 500; @@ -29,20 +47,9 @@ const SHARE_UI_STATE_PARAM = 'ui'; const SHARE_STYLE_PARAMS_PARAM = 'sp'; const SHARE_CREATED_AT_PARAM = 'at'; -const SHARE_PANEL_STATE_REGISTRY = Object.freeze([ - { id: 'control-panel', token: 'c', pinnable: true }, - { id: 'location-bar', token: 'l', pinnable: true }, - { id: 'data-panel', token: 'd', pinnable: false }, - { id: 'cctv-panel', token: 'v', pinnable: false }, - { id: 'recent-imagery-panel', token: 'i', pinnable: false }, - { id: 'street-level-panel', token: 't', pinnable: false }, - { id: 'radio-panel', token: 'r', pinnable: false }, - { id: 'scene-panel', token: 's', pinnable: false }, - { id: 'global-context-panel', token: 'g', pinnable: false }, - { id: 'pp-toggles', token: 'p', pinnable: false }, - { id: 'param-slider-panel', token: 'm', pinnable: false }, -]); - +// The panel and visual-preset parameter tables are the share format's own +// (src/share/viewCodec.js); the long form spells their entries by token. +const SHARE_PANEL_STATE_REGISTRY = SHARE_VIEW_PANELS; const SHARE_PANEL_STATE_BY_TOKEN = Object.freeze( new Map(SHARE_PANEL_STATE_REGISTRY.map((entry) => [entry.token, entry])), ); @@ -51,46 +58,25 @@ const URL_TO_STYLE = Object.fromEntries( Object.entries(STYLE_TO_URL).map(([k, v]) => [v, k]), ); -const SHARE_STYLE_PARAM_REGISTRY = Object.freeze({ - retro: Object.freeze([ - { key: 'pixelation', token: 'p', min: 1, max: 10 }, - { key: 'distortion', token: 'd', min: 0, max: 1 }, - { key: 'instability', token: 'i', min: 0, max: 1 }, - ]), - surveillance: Object.freeze([ - { key: 'gain', token: 'g', min: 0, max: 1 }, - { key: 'bloom', token: 'b', min: 0, max: 1 }, - { key: 'scanlineStr', token: 's', min: 0, max: 1 }, - { key: 'pixelation', token: 'p', min: 1, max: 6 }, - ]), - thermal: Object.freeze([ - { key: 'sensitivity', token: 's', min: 0, max: 1 }, - { key: 'bloom', token: 'b', min: 0, max: 1 }, - { key: 'mode', token: 'm', min: 0, max: 1 }, - { key: 'pixelation', token: 'p', min: 1, max: 6 }, - { key: 'palette', token: 'a', min: 0, max: 1 }, - ]), - anime: Object.freeze([ - { key: 'saturation', token: 's', min: 0, max: 2 }, - { key: 'edgeThick', token: 'e', min: 0, max: 1 }, - ]), - noir: Object.freeze([ - { key: 'contrastAmt', token: 'c', min: 0, max: 2 }, - { key: 'grainAmt', token: 'g', min: 0, max: 1 }, - { key: 'vignetteAmt', token: 'v', min: 0, max: 1 }, - ]), - snow: Object.freeze([ - { key: 'density', token: 'd', min: 0, max: 1 }, - { key: 'wind', token: 'w', min: 0, max: 1 }, - ]), -}); +const SHARE_STYLE_PARAM_REGISTRY = SHARE_VIEW_STYLE_PARAMS; export class ShareLinkManager { constructor( viewer, - { onRestore, isNavigationCurrent, cancelOwnedNavigation } = {}, + { + onRestore, + isNavigationCurrent, + cancelOwnedNavigation, + initialShareCode = null, + } = {}, ) { this.viewer = viewer; + // A host may hand over the share code to open instead of the address. + this._initialShareCode = + typeof initialShareCode === 'string' ? initialShareCode : null; + // Why the share code this page opened with was refused, if it was. + this.rejectedShareCode = null; + this._followSubjectProvider = null; this._onRestore = onRestore; // callback: ({ style, bloom, sharpen }) => void this._debounceTimer = null; this._currentStyle = 'normal'; @@ -155,17 +141,42 @@ export class ShareLinkManager { } /** - * Parse URL hash on page load. Returns parsed state or null. + * Parse the share this page opened with: the host's `initialShareCode`, a + * `/share/` address, or a long-form hash. Returns the parsed state, or + * null for none. A share code that cannot be opened is recorded in + * `rejectedShareCode` and restores nothing. */ parseInitialHash() { const hash = window.location.hash.slice(1); this._knownHash = hash; - const state = this._parseShareHash(hash); + const code = + this._initialShareCode ?? + parseSharePath(window.location.pathname)?.code ?? + null; + this._initialShareCode = null; + const state = + code === null ? this._parseShareHash(hash) : this._parseShareCode(code); if (!state) return null; this._beginIncomingRestore(state); return state; } + /** + * The restore state for a share code, in the long form's parsed shape plus + * `shareView` (what the view follows, with its offset and last-known fix), + * or null when the code is refused. + */ + _parseShareCode(code) { + const result = decodeShareView(code); + if (!result.ok) { + this.rejectedShareCode = result.reason; + return null; + } + const state = toLegacyShareState(result.state); + state.shareView = result.state.view; + return state; + } + /** Parse one hash without side effects. Returns null unless it is a share. */ _parseShareHash(hash) { if (!hash) return null; @@ -459,6 +470,16 @@ export class ShareLinkManager { this._layerStateProvider = typeof provider === 'function' ? provider : null; } + /** + * Install the source of the entity the camera follows, used for share links: + * `({ nowMs }) => ({ kind, id, offset, lastKnown }) | null`, in the share + * format's terms (src/share/follow.js), with the fix as of `nowMs`. + */ + setFollowSubjectProvider(provider) { + this._followSubjectProvider = + typeof provider === 'function' ? provider : null; + } + /** Install the finalized panel-state source used by URL generation. */ setPanelStateProvider(provider) { this._panelStateProvider = typeof provider === 'function' ? provider : null; @@ -559,17 +580,41 @@ export class ShareLinkManager { this._scheduleUpdate(); } + /** + * The share link for the current state, `/share/`, stamped with + * its creation time, or null before the camera has a position. While the + * camera follows an entity, the link follows it too. + */ + buildLink({ nowMs = Date.now() } = {}) { + const params = this._buildHashParams(); + if (!params) return null; + // The long form is the one description of the live state; the code is + // that state in the share format. + const state = fromLegacyShareState(this._parseShareHash(params.toString())); + state.sharedAtMs = Math.floor(nowMs / 1000) * 1000; + let subject = null; + try { + subject = this._followSubjectProvider?.({ nowMs }) ?? null; + } catch { + subject = null; + } + if (subject) { + state.view = { + kind: 'thing', + entity: { kind: subject.kind, id: subject.id }, + offset: subject.offset ?? null, + lastKnown: subject.lastKnown ?? null, + }; + } + return shareLinkUrl(encodeShareView(state), window.location.href); + } + /** Copy a current-state snapshot with a copy-time timestamp. Returns true on success. */ async copyLink({ nowMs = Date.now() } = {}) { - const params = this._buildHashParams(); - if (!params) return false; - params.set(SHARE_CREATED_AT_PARAM, String(Math.floor(nowMs / 1000))); - const copiedUrl = new URL(window.location.href); - // ?lang is the sender's language override; the recipient keeps their own. - copiedUrl.searchParams.delete('lang'); - copiedUrl.hash = params.toString(); try { - await navigator.clipboard.writeText(copiedUrl.href); + const href = this.buildLink({ nowMs }); + if (!href) return false; + await navigator.clipboard.writeText(href); return true; } catch { return false; @@ -587,8 +632,13 @@ export class ShareLinkManager { const params = this._buildHashParams(); if (!params) return; // The hash belongs to this page's address, not the document base the - // app may load under. - const url = new URL(`#${params.toString()}`, window.location.href); + // app may load under. A `/share/` address gives way to the app's + // own address, so a reload follows the live view; replacing it adds no + // history entry. + const url = parseSharePath(new URL(window.location.href).pathname) + ? appRootUrl(window.location.href) + : new URL(window.location.href); + url.hash = params.toString(); this._knownHash = url.hash.slice(1); history.replaceState(null, '', url.href); } @@ -649,8 +699,8 @@ export class ShareLinkManager { this._styleParamStateProvider?.(this._currentStyle), ); - // Copy-time metadata is intentionally absent here. `copyLink()` adds a - // fresh timestamp to its ephemeral URL without aging the live address. + // Creation-time metadata is intentionally absent here. `buildLink()` + // stamps its own link without aging the live address. params.delete(SHARE_CREATED_AT_PARAM); return params; } @@ -677,6 +727,7 @@ export class ShareLinkManager { this._layerStateProvider = null; this._panelStateProvider = null; this._styleParamStateProvider = null; + this._followSubjectProvider = null; this._onRestore = null; } } diff --git a/src/testSupport/shareViewFixtures.mjs b/src/testSupport/shareViewFixtures.mjs new file mode 100644 index 000000000..629132459 --- /dev/null +++ b/src/testSupport/shareViewFixtures.mjs @@ -0,0 +1,103 @@ +/** + * Shared fixtures for the share-view codec tests: a seeded generator and + * real long-form links. + */ +import { + REGISTERED_LAYER_IDS, + createDefaultLayerState, + optionSpecs, +} from '../data/layerState.js'; + +/** A long-form link copied from a real session. */ +export const HAL_EXAMPLE_HASH = + 'v=2&lat=30.2672&lon=-97.7431&alt=600&heading=15&pitch=-30&roll=360&style=normal&bloom=0&sharpen=1&bi=0&bv=2&si=49&hud=tactical&hv=1&dm=DENSE&dd=75&da=elastic&kf=7&ko=1&cr=0&sc=1&scf=11&map=photoreal&l=a.f.s&lo=f.e.1_k.o.n&ui=c.c.1_c.p.0_l.c.1_l.p.0_d.c.1_v.c.0_i.c.0_t.c.1_r.c.1_s.c.1_g.c.1_p.c.1_m.c.0'; + +/** Deterministic generator (mulberry32) for property-style tests. */ +export function rng(seed) { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const OPTION_CANDIDATES = [ + true, + false, + 'light', + 'strong', + 'gfs', + 'ifs', + 'none', + 'speed', + 'temperature', + 'pressure', + 'km/h', + 'm/s', + 'mph', + 'proximity', + 'all', + 'core', + 'dense', + 'off', + 'on', + 'viewshed', + 'tomtom', + 'osm', + 'hybrid', + 'clouds', + 'clouds-regional', + 'pano', + 'flat', + 'filtered', + 'full', + 0, + 1, + 2, + 37, + 50, + 100, + 365, + -12345678, + 12345678, + 8505110, + 'S30:2026-09-18', + 'L30:2024-02-29', + 'VIIRS:2025-01-02', + 'genre:jazz & blues', + 'news', + 'public-safety', + 0.35, + 0.8, + 'abc-1*def-0', + 'osm-1', + 25544, + 'a1b2c3', + '~abc123', +]; + +/** A random layer state using only values each option accepts. */ +export function randomLayerState(random) { + const state = createDefaultLayerState(); + state.enabledLayerIds = REGISTERED_LAYER_IDS.filter(() => random() < 0.3); + for (const ownerId of Object.keys(state.options)) { + for (const spec of optionSpecs(ownerId)) { + if (random() < 0.5) continue; + const accepted = OPTION_CANDIDATES.filter( + (value) => spec.normalize(value) !== null, + ); + if (accepted.length) + state.options[ownerId][spec.key] = + accepted[Math.floor(random() * accepted.length)]; + } + } + return state; +} + +/** One element of `values`, chosen by `random`. */ +export function pick(random, values) { + return values[Math.floor(random() * values.length)]; +} diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 4552da8d4..e3ed24867 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -35,6 +35,10 @@ import * as Cesium from 'cesium'; import { aircraftTrackingTarget } from '../cockpitTracking.js'; import { ShellFeedback } from './shellFeedback.js'; +import { + applyShareFollowOffset, + readShareFollowSubject, +} from './shareFollow.js'; import { runCctvLayerEnableTransition } from '../cctvFocusPolicy.js'; @@ -74,7 +78,13 @@ export class StyleManager extends ShellFacade { */ constructor( viewer, - { mapStackController = null, placeSearch, services, requestServices } = {}, + { + mapStackController = null, + placeSearch, + services, + requestServices, + initialShareCode = null, + } = {}, ) { super(); const { @@ -165,6 +175,8 @@ export class StyleManager extends ShellFacade { this._showGlobalStatusNotice(message, options), feedback: this._feedback, updateFeedback: () => this._updateGlobalLoadingFeedback(), + applyFollowOffset: (entity, offset) => + applyShareFollowOffset(viewer, entity, offset), }); this._visualSettings = new VisualSettings({ @@ -430,7 +442,12 @@ export class StyleManager extends ShellFacade { isNavigationCurrent: (generation) => generation === this._navigationGeneration, cancelOwnedNavigation: () => this.viewer.camera.cancelFlight(), + // A host may open a share code without a `/share/` address. + initialShareCode, }); + this.shareLinkManager.setFollowSubjectProvider(({ nowMs }) => + readShareFollowSubject({ viewer: this.viewer, nowMs }), + ); this.shareLinkManager.setPanelStateProvider(() => this._buildSharePanelState(), ); @@ -1652,14 +1669,35 @@ export class StyleManager extends ShellFacade { // ── Share Button ───────────────────────────── /** - * Wires the share button click to copy the current share link to the clipboard. + * Wires the share button. By default it copies the current share link to the + * clipboard; a host may pass `services.shareLink(url)` to share it another way + * (a system share sheet, for example). It resolves to 'shared', 'copied', + * 'cancelled' (no toast) or false (failed). * @returns {void} */ _initShareButton() { this._lifetime.listen(this._shareBtn, 'click', async () => { - const success = await this.shareLinkManager.copyLink(); - if (!this._disposed) - this._showToast(success ? t('Link copied!') : t('Copy failed')); + const share = this.services.shareLink; + let result; + if (typeof share === 'function') { + // A host's hook may return a plain value or throw; both are answers. + result = await Promise.resolve() + .then(() => { + const href = this.shareLinkManager.buildLink(); + return href ? share(href) : false; + }) + .catch(() => false); + } else { + result = (await this.shareLinkManager.copyLink()) ? 'copied' : false; + } + if (this._disposed || result === 'cancelled') return; + this._showToast( + result === 'shared' + ? t('Link shared') + : result === 'copied' + ? t('Link copied!') + : t('Copy failed'), + ); }); } diff --git a/src/ui/shareButton.test.mjs b/src/ui/shareButton.test.mjs new file mode 100644 index 000000000..e5e8422c0 --- /dev/null +++ b/src/ui/shareButton.test.mjs @@ -0,0 +1,160 @@ +// The share button copies by default; a host's `services.shareLink` shares the +// link its own way and reports how it went. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { StyleManager } from './applicationShell.js'; +import { ShareLinkManager } from '../sharelink.js'; +import { parseSharePath } from '../share/shareLinkPath.js'; +import { decodeShareView } from '../share/viewCodec.js'; + +/** The decoded view of a `/share/` link. */ +const sharedState = (href) => + decodeShareView(parseSharePath(new URL(href).pathname).code).state; +const SHARE_LINK = /^https:\/\/gev\.example\/share\/[A-Za-z0-9_-]+$/; + +function makeManager(href, { positioned = true } = {}) { + globalThis.window = { location: { href, hash: new URL(href).hash } }; + globalThis.history = { replaceState() {} }; + const viewer = { + camera: { + changed: { addEventListener() {} }, + positionCartographic: positioned + ? { latitude: 0, longitude: 0, height: 1000 } + : undefined, + heading: 0, + pitch: -Math.PI / 2, + roll: 0, + }, + }; + return new ShareLinkManager(viewer); +} + +function installClipboard(writeText) { + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { clipboard: { writeText } }, + }); +} + +/** Wire the real share button handler onto a minimal shell; return a click. */ +function shell({ manager, services = {} }) { + const toasts = []; + let click; + const owner = { + services, + shareLinkManager: manager, + _shareBtn: {}, + _disposed: false, + _lifetime: { listen: (_el, _type, handler) => (click = handler) }, + _showToast: (message) => toasts.push(message), + }; + StyleManager.prototype._initShareButton.call(owner); + return { toasts, click: () => click() }; +} + +test('buildLink is a /share link stamped with its creation time, without ?lang', () => { + const manager = makeManager('https://gev.example/?lang=ru#v=2&lat=10&lon=20'); + const href = manager.buildLink({ nowMs: 5_000_000 }); + assert.match(href, SHARE_LINK); + const link = new URL(href); + assert.equal(link.searchParams.get('lang'), null); + assert.equal(link.hash, ''); + assert.equal(sharedState(href).sharedAtMs, 5_000_000); + // The camera is the live one, not the address bar's. + assert.equal(sharedState(href).lat, 0); + assert.equal( + makeManager('https://gev.example/', { positioned: false }).buildLink(), + null, + ); +}); + +test('copyLink copies exactly what buildLink builds', async () => { + let copied = null; + installClipboard(async (url) => { + copied = url; + }); + const manager = makeManager('https://gev.example/#v=2'); + assert.equal(await manager.copyLink({ nowMs: 7_000_000 }), true); + assert.equal(copied, manager.buildLink({ nowMs: 7_000_000 })); +}); + +test('without a host hook the button copies the link', async () => { + let copied = null; + installClipboard(async (url) => { + copied = url; + }); + const { toasts, click } = shell({ + manager: makeManager('https://gev.example/#v=2'), + }); + await click(); + assert.match(copied, SHARE_LINK); + assert.deepEqual(toasts, ['Link copied!']); + + installClipboard(async () => { + throw new Error('denied'); + }); + await click(); + assert.deepEqual(toasts, ['Link copied!', 'Copy failed']); +}); + +test('a host hook receives the link and its result picks the toast', async () => { + installClipboard(async () => assert.fail('the host shares instead')); + const shared = []; + const results = ['shared', 'copied', 'cancelled', false]; + const { toasts, click } = shell({ + manager: makeManager('https://gev.example/#v=2'), + services: { + shareLink: async (url) => { + shared.push(url); + return results.shift(); + }, + }, + }); + for (let i = 0; i < 4; i++) await click(); + assert.equal(shared.length, 4); + assert.match(shared[0], SHARE_LINK); + // 'cancelled' (the user closed the sheet) shows nothing. + assert.deepEqual(toasts, ['Link shared', 'Link copied!', 'Copy failed']); +}); + +test('a failing host hook, or no camera position, reports a failure', async () => { + const failing = shell({ + manager: makeManager('https://gev.example/#v=2'), + services: { shareLink: async () => Promise.reject(new Error('no sheet')) }, + }); + await failing.click(); + assert.deepEqual(failing.toasts, ['Copy failed']); + + let called = false; + const unpositioned = shell({ + manager: makeManager('https://gev.example/', { positioned: false }), + services: { + shareLink: async () => { + called = true; + return 'shared'; + }, + }, + }); + await unpositioned.click(); + assert.equal(called, false); + assert.deepEqual(unpositioned.toasts, ['Copy failed']); +}); + +test('a host hook that answers without a promise, or throws, still gets a toast', async () => { + for (const [shareLink, toast] of [ + [() => 'shared', 'Link shared'], + [ + () => { + throw new Error('no sheet'); + }, + 'Copy failed', + ], + ]) { + const { toasts, click } = shell({ + manager: makeManager('https://gev.example/#v=2'), + services: { shareLink }, + }); + await click(); + assert.deepEqual(toasts, [toast]); + } +}); diff --git a/src/ui/shareFollow.js b/src/ui/shareFollow.js new file mode 100644 index 000000000..ba5851da6 --- /dev/null +++ b/src/ui/shareFollow.js @@ -0,0 +1,190 @@ +import * as Cesium from 'cesium'; +import { trackedDisplayPositionForCamera } from '../data/trackedCamera.js'; +import { + SHARE_VIEW_ENTITY_KINDS, + followOffsetFromEnu, + followOffsetToEnu, +} from '../share/follow.js'; + +/** + * The bridge between the camera following an entity and a share link's thing + * view: what the camera follows now, in the share format's terms, and the + * stored follow offset applied to an entity the camera has started to follow. + */ + +const KIND_BY_LAYER = new Map( + SHARE_VIEW_ENTITY_KINDS.map((entry) => [entry.layerId, entry.kind]), +); +const MAX_FRAMES = 120; + +/** `position`'s local east/north/up offset from `origin`, in metres. */ +function enuOffset(origin, position) { + const frame = Cesium.Transforms.eastNorthUpToFixedFrame(origin); + const local = Cesium.Matrix4.multiplyByPoint( + Cesium.Matrix4.inverseTransformation(frame, new Cesium.Matrix4()), + position, + new Cesium.Cartesian3(), + ); + return { east: local.x, north: local.y, up: local.z }; +} + +/** + * The entity the camera follows as `{ kind, id, offset, lastKnown }`, with + * the camera's offset from it and its displayed position as of `nowMs`; null + * when the camera follows nothing a share link can name. A selected vessel is + * not followed (the camera stays where the user puts it), so a link made then + * keeps its own camera. + */ +export function readShareFollowSubject({ viewer, nowMs = Date.now() }) { + const cameraPosition = viewer?.camera?.positionWC; + if (!cameraPosition) return null; + const tracked = viewer.trackedEntity; + const trackedId = String(tracked?.gevTrackedId || ''); + const separator = trackedId.indexOf(':'); + if (separator <= 0) return null; + const kind = KIND_BY_LAYER.get(trackedId.slice(0, separator)); + const id = trackedId.slice(separator + 1); + const target = kind + ? trackedDisplayPositionForCamera( + tracked, + viewer.clock.currentTime, + new Cesium.Cartesian3(), + ) + : null; + if (!kind || !id || !target) return null; + const offset = followOffsetFromEnu(enuOffset(target, cameraPosition)); + const fix = Cesium.Cartographic.fromCartesian(target); + if (!offset || !fix) return null; + return { + kind, + id, + offset, + lastKnown: { + lat: Cesium.Math.toDegrees(fix.latitude), + lon: Cesium.Math.toDegrees(fix.longitude), + alt: fix.height, + observedAtMs: nowMs, + }, + }; +} + +/** Largest turn, in radians, the camera makes around the entity per frame. */ +const MAX_TURN_PER_FRAME = Math.PI / 3; + +/** + * Put the camera at a shared follow offset from `entity` once following it + * has framed the camera. Cesium keeps the offset afterwards, as it keeps one + * the user orbits to. The camera swings round the entity at most 60° a frame: + * the follow's own guard treats a jump to the far side as the camera crossing + * through the entity and puts it back. Returns a disposer. + */ +export function applyShareFollowOffset(viewer, entity, offset) { + if (!viewer?.scene?.preUpdate || !entity || !(offset?.range > 0)) + return () => {}; + const local = followOffsetToEnu(offset); + let frames = 0; + let remove = null; + const stop = () => { + remove?.(); + remove = null; + }; + remove = viewer.scene.preUpdate.addEventListener(() => { + if (viewer.isDestroyed?.() || viewer.trackedEntity !== entity) { + stop(); + return; + } + frames += 1; + const camera = viewer.camera; + // Let the follow frame the camera first (its first frame), then move it. + const framed = + frames > 1 && + !Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY); + const target = framed + ? trackedDisplayPositionForCamera( + entity, + viewer.clock.currentTime, + new Cesium.Cartesian3(), + ) + : null; + if (!target) { + if (frames >= MAX_FRAMES) stop(); + return; + } + // The offset is east/north/up at the entity; the follow's own frame may + // differ, so place the camera in world space and re-express it there. + const world = Cesium.Matrix4.multiplyByPoint( + Cesium.Transforms.eastNorthUpToFixedFrame(target), + new Cesium.Cartesian3(local.east, local.north, local.up), + new Cesium.Cartesian3(), + ); + const goal = Cesium.Matrix4.multiplyByPoint( + Cesium.Matrix4.inverseTransformation( + camera.transform, + new Cesium.Matrix4(), + ), + world, + new Cesium.Cartesian3(), + ); + const from = Cesium.Cartesian3.clone( + camera.position, + new Cesium.Cartesian3(), + ); + const angle = + Cesium.Cartesian3.magnitude(from) > 0 + ? Cesium.Cartesian3.angleBetween(from, goal) + : 0; + const done = !(angle > MAX_TURN_PER_FRAME); + const next = done ? goal : turnToward(from, goal, MAX_TURN_PER_FRAME); + camera.lookAtTransform(camera.transform, next); + viewer.scene.requestRender?.(); + if (done) stop(); + }); + return stop; +} + +/** + * `from` turned `step` radians toward `to`, with its length moved the same + * fraction of the way: one frame of the swing round the entity. + */ +function turnToward(from, to, step) { + const angle = Cesium.Cartesian3.angleBetween(from, to); + const unitFrom = Cesium.Cartesian3.normalize(from, new Cesium.Cartesian3()); + // The direction perpendicular to `from` in the plane toward `to` (or any + // perpendicular when they are opposite). + let toward = Cesium.Cartesian3.subtract( + Cesium.Cartesian3.normalize(to, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar( + unitFrom, + Math.cos(angle), + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + if (Cesium.Cartesian3.magnitude(toward) < 1e-9) + toward = Cesium.Cartesian3.cross( + unitFrom, + Math.abs(unitFrom.z) < 0.9 + ? Cesium.Cartesian3.UNIT_Z + : Cesium.Cartesian3.UNIT_X, + new Cesium.Cartesian3(), + ); + Cesium.Cartesian3.normalize(toward, toward); + const length = + Cesium.Cartesian3.magnitude(from) + + (Cesium.Cartesian3.magnitude(to) - Cesium.Cartesian3.magnitude(from)) * + (step / angle); + const direction = Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar( + unitFrom, + Math.cos(step), + new Cesium.Cartesian3(), + ), + Cesium.Cartesian3.multiplyByScalar( + toward, + Math.sin(step), + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + return Cesium.Cartesian3.multiplyByScalar(direction, length, direction); +} diff --git a/src/ui/shareLinks.test.mjs b/src/ui/shareLinks.test.mjs new file mode 100644 index 000000000..d7b747d66 --- /dev/null +++ b/src/ui/shareLinks.test.mjs @@ -0,0 +1,554 @@ +// Short share links: `/share/` opens through the start-up restore +// pipeline, the address then follows the live view, and the share button +// makes `/share/` links (following an entity when the camera does). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { ShareLinkManager } from '../sharelink.js'; +import { ShareRestoration, sharedFixNotice } from './shareRestoration.js'; +import { + applyShareFollowOffset, + readShareFollowSubject, +} from './shareFollow.js'; +import { createDefaultLayerState } from '../data/layerState.js'; +import { parseSharePath, shareLinkUrl } from '../share/shareLinkPath.js'; +import { + decodeShareView, + encodeShareView, + followCameraFor, + followOffsetToEnu, +} from '../share/viewCodec.js'; + +const NOW_MS = Date.now(); +const FIX = { lat: 37.6213, lon: -122.379, alt: 3200 }; +const OFFSET = { range: 5800, heading: 0, pitch: -35 }; + +const PLACE_CODE = encodeShareView({ + lat: 48.8584, + lon: 2.2945, + alt: 1200, + heading: 120, + pitch: -25, + style: 'noir', + panelState: { specs: [{ id: 'data-panel', collapsed: false }] }, + sharedAtMs: NOW_MS - 60_000, +}); + +function aircraftCode() { + const layerState = createDefaultLayerState(); + layerState.enabledLayerIds = ['flights']; + return encodeShareView({ + view: { + kind: 'thing', + entity: { kind: 'aircraft', id: 'a1b2c3' }, + offset: OFFSET, + lastKnown: { ...FIX, observedAtMs: NOW_MS - 120_000 }, + }, + layerState, + sharedAtMs: NOW_MS - 120_000, + }); +} + +/** + * A page at `href`. `history.replaceState` rewrites the address in place and + * is counted; `pushState` (a new history entry) is counted too. + */ +function installPage(t, href) { + const prior = { window: globalThis.window, history: globalThis.history }; + const target = new EventTarget(); + const location = new URL(href); + target.location = location; + globalThis.window = target; + const writes = { replaced: 0, pushed: 0 }; + globalThis.history = { + replaceState(_state, _title, next) { + writes.replaced += 1; + location.href = new URL(next, location.href).href; + }, + pushState() { + writes.pushed += 1; + }, + }; + t.after(() => Object.assign(globalThis, prior)); + return { location, writes, target }; +} + +function installManualTimers(t) { + const prior = { + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + }; + const timers = new Map(); + let timerId = 0; + globalThis.setTimeout = (fn) => { + timers.set(++timerId, fn); + return timerId; + }; + globalThis.clearTimeout = (id) => timers.delete(id); + t.after(() => Object.assign(globalThis, prior)); + return { + timers, + runAll() { + for (const [id, fn] of [...timers]) { + timers.delete(id); + fn(); + } + }, + }; +} + +/** A viewer whose camera flights land at once. */ +function fakeViewer() { + const flights = []; + return { + flights, + canvas: new EventTarget(), + camera: { + changed: { addEventListener: () => () => {} }, + positionCartographic: { latitude: 0, longitude: 0, height: 1000 }, + heading: 0, + pitch: -Math.PI / 2, + roll: 0, + flyTo(options) { + flights.push(options); + options.complete?.(); + }, + setView() {}, + }, + }; +} + +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +/** A ShareRestoration over a real ShareLinkManager, as the shell wires them. */ +function openPage(t, href, { managerOptions = {}, tracking = null } = {}) { + const page = installPage(t, href); + const clock = installManualTimers(t); + const viewer = fakeViewer(); + const restores = []; + const manager = new ShareLinkManager(viewer, { + onRestore: async (state) => restores.push(state), + isNavigationCurrent: () => true, + ...managerOptions, + }); + const notices = []; + let generation = 0; + const owner = new ShareRestoration({ + viewer, + navigation: { + _beginDeferredNavigation: () => ++generation, + _reassertNavigationHandoff: () => true, + _stampNavigation() {}, + }, + syncShareState() {}, + syncModels3d() {}, + showStatus() {}, + feedback: {}, + updateFeedback() {}, + applyFollowOffset: (entity, offset) => + applyShareFollowOffset(viewer, entity, offset), + }); + // Notices are checked by message; their start-up-cover timing is not. + owner._presentAfterStartupCover = (message) => notices.push(message); + owner.attachLinks(manager); + owner._layerStateCoordinator = { + applyShareLayerState: () => Promise.resolve([]), + getDurableState: () => null, + cancelPendingShareTracking: () => false, + restoreShareTrackingSelection: async () => tracking?.(owner) ?? null, + destroy() {}, + }; + owner.start(); + t.after(() => owner.destroy()); + return { ...page, ...clock, viewer, manager, owner, restores, notices }; +} + +/** Run the delayed start-up restore and let it settle. */ +async function restore(page) { + page.runAll(); + const result = await page.owner.initialRestorePromise; + await flush(); + return result; +} + +test('a /share/ address opens its place view through the start-up restore', async (t) => { + const page = openPage(t, `http://localhost/share/${PLACE_CODE}`); + assert.equal(page.timers.size, 1, 'the restore waits for its delayed flight'); + assert.equal(page.manager._initialRestorePending, true); + const result = await restore(page); + assert.equal(result.status, 'settled'); + assert.equal(result.share.camera, 'applied'); + assert.equal(page.restores[0].style, 'noir'); + assert.deepEqual(page.restores[0].panelState.specs, [ + { id: 'data-panel', collapsed: false, pinned: null }, + ]); + const destination = Cesium.Cartographic.fromCartesian( + page.viewer.flights[0].destination, + ); + assert.ok( + Math.abs(Cesium.Math.toDegrees(destination.latitude) - 48.8584) < 1e-6, + ); + assert.equal(page.manager._initialRestorePending, false); + assert.deepEqual(page.notices, []); +}); + +test('after a restore the address becomes the app root and the long hash, with no new history entry', async (t) => { + for (const [href, root] of [ + [`http://localhost/share/${PLACE_CODE}`, 'http://localhost/'], + [ + `http://localhost:4173/gev/share/${PLACE_CODE}`, + 'http://localhost:4173/gev/', + ], + ]) { + const page = openPage(t, href); + await restore(page); + page.manager._updateHash(); + assert.equal(`${page.location.origin}${page.location.pathname}`, root); + // The live view's long form, as written for any other page. + const params = new URLSearchParams(page.location.hash.slice(1)); + assert.equal(params.get('v'), '2'); + assert.ok(params.has('lat') && params.has('style')); + assert.equal(page.writes.pushed, 0); + assert.equal(page.writes.replaced, 1); + page.manager.destroy(); + } +}); + +test('a host can pass the share code instead of the address', async (t) => { + const page = openPage(t, 'http://localhost/', { + managerOptions: { initialShareCode: PLACE_CODE }, + }); + await restore(page); + assert.equal(page.restores[0].style, 'noir'); +}); + +test('a code that fails its check restores nothing and says so', async (t) => { + // One character changed: the check byte no longer matches. + const last = PLACE_CODE.at(-2) === 'A' ? 'B' : 'A'; + const typo = `${PLACE_CODE.slice(0, -2)}${last}${PLACE_CODE.at(-1)}`; + const page = openPage(t, `http://localhost/share/${typo}`); + assert.equal(page.manager.rejectedShareCode, 'checksum'); + assert.notEqual(page.owner._hasShareState, true); + assert.equal(page.timers.size, 0, 'nothing to restore'); + assert.equal( + (await page.owner.initialRestorePromise).status, + 'not-requested', + ); + assert.deepEqual(page.notices, ["This share link can't be opened"]); + // The default start's first address write leaves the bad link behind. + page.manager._updateHash(); + assert.equal(page.location.pathname, '/'); +}); + +test('a thing view turns its layer on, follows by id and frames the shared offset', async (t) => { + let entity = null; + const page = openPage(t, `http://localhost/share/${aircraftCode()}`, { + tracking: (owner) => { + // What the coordinator does when the aircraft is in the feed. + owner.viewer.trackedEntity = entity; + owner._handleShareTrackingRestoreStatus({ + layerId: 'flights', + targetId: 'a1b2c3', + classification: 'followed', + }); + return { classification: 'followed' }; + }, + }); + const target = Cesium.Cartesian3.fromDegrees(FIX.lon, FIX.lat, FIX.alt); + const listeners = []; + const looks = []; + Object.assign(page.viewer, { + clock: { currentTime: Cesium.JulianDate.now() }, + scene: { + preUpdate: { + addEventListener(fn) { + listeners.push(fn); + return () => listeners.splice(listeners.indexOf(fn), 1); + }, + }, + }, + isDestroyed: () => false, + }); + Object.assign(page.viewer.camera, { + transform: Cesium.Transforms.eastNorthUpToFixedFrame(target), + // The app's own follow framing: south of the aircraft and above it. + position: new Cesium.Cartesian3(0, -4800, 3300), + lookAtTransform(transform, offset) { + looks.push(offset); + this.position = Cesium.Cartesian3.clone(offset); + }, + }); + entity = { + gevTrackedId: 'flights:a1b2c3', + gevDisplayPosition: () => target, + }; + + const parsed = page.manager._parseShareCode(aircraftCode()); + assert.deepEqual(parsed.layerState.enabledLayerIds, ['flights']); + assert.equal( + parsed.layerState.options.flights.selectedFlightsTrackingId, + 'a1b2c3', + ); + await restore(page); + // The opening flight goes where following the fix puts the camera. + const camera = followCameraFor(FIX, OFFSET); + const destination = Cesium.Cartographic.fromCartesian( + page.viewer.flights[0].destination, + ); + assert.ok( + Math.abs(Cesium.Math.toDegrees(destination.latitude) - camera.lat) < 1e-3, + ); + assert.ok( + Math.abs(Cesium.Math.toDegrees(destination.longitude) - camera.lon) < 1e-3, + ); + // Once followed, the camera takes the shared offset after the follow's + // own first frame. + assert.equal(listeners.length, 1); + listeners[0](); + assert.equal(looks.length, 0); + listeners[0]?.(); + assert.equal(looks.length, 1); + const expected = followOffsetToEnu(OFFSET); + assert.ok(Math.abs(looks[0].x - expected.east) < 1); + assert.ok(Math.abs(looks[0].y - expected.north) < 1); + assert.ok(Math.abs(looks[0].z - expected.up) < 1); + assert.equal(listeners.length, 0, 'the offset is applied once'); + assert.deepEqual(page.notices, []); +}); + +test('a thing that is not reporting leaves the view at its last-known fix, with a notice', async (t) => { + const page = openPage(t, `http://localhost/share/${aircraftCode()}`, { + tracking: (owner) => { + // The coordinator's verdict once the follow window has passed. + const result = { + layerId: 'flights', + targetId: 'a1b2c3', + label: 'flight', + classification: 'expired', + }; + owner._handleShareTrackingRestoreStatus(result); + return result; + }, + }); + await restore(page); + await flush(); + assert.equal(page.notices.length, 1); + assert.match( + page.notices[0], + /^A1B2C3 isn't reporting now — showing where it was at .+/, + ); + // A long link names no fix and keeps the existing notice. + page.owner._initialShareState = { shareView: null }; + page.owner._handleShareTrackingRestoreStatus({ + layerId: 'flights', + targetId: 'a1b2c3', + label: 'flight', + classification: 'expired', + }); + assert.equal(page.notices[1], 'Shared flight follow expired'); +}); + +test('the not-reporting notice names the id and the time of the fix', () => { + const at = new Date(2026, 9, 26, 14, 5).getTime(); + assert.match( + sharedFixNotice({ kind: 'aircraft', id: 'a1b2c3' }, at, at + 60_000), + /^A1B2C3 isn't reporting now — showing where it was at .*05/, + ); + assert.equal( + sharedFixNotice({ kind: 'vessel', id: '366999712' }, null), + "366999712 isn't reporting now — showing where it was last seen", + ); +}); + +test('a shared vessel is selected once the vessel feed has it', async (t) => { + t.mock.timers.enable({ apis: ['Date'], now: NOW_MS }); + const page = openPage(t, 'http://localhost/'); + const selected = []; + let present = false; + page.owner._dataManager = { + layers: new Map([ + [ + 'ais-live-vessels', + { + module: { + hasContact: (id) => present && id === '366999712', + selectById: (id) => selected.push(id) > 0, + }, + }, + ], + ]), + }; + const view = { + kind: 'thing', + entity: { kind: 'vessel', id: '366999712' }, + lastKnown: { lat: 1, lon: 2, alt: 0, observedAtMs: NOW_MS - 30_000 }, + }; + const found = page.owner._restoreSharedVessel(view, () => true); + await flush(); + present = true; + page.runAll(); + assert.equal(await found, true); + assert.deepEqual(selected, ['366999712']); + + present = false; + const missing = page.owner._restoreSharedVessel(view, () => true); + for (let i = 0; i < 61; i += 1) { + await flush(); + t.mock.timers.tick(2_000); + page.runAll(); + } + assert.equal(await missing, false); + assert.match(page.notices.at(-1), /^366999712 isn't reporting now/); +}); + +test('the share button links to /share/, following the followed entity', (t) => { + installPage(t, 'https://gev.example/?lang=ru#v=2&lat=1&lon=2'); + const manager = new ShareLinkManager(fakeViewer()); + // A fresh session's look, as the app reports it at start-up. + manager.onToggleChange(false, true, { + hudVisible: true, + detectionMode: 'DENSE', + detectionDensity: 75, + }); + // No entity: a place view at the live camera. + const place = manager.buildLink({ nowMs: NOW_MS }); + assert.match(place, /^https:\/\/gev\.example\/share\/[A-Za-z0-9_-]+$/); + assert.equal( + decodeShareView(parseSharePath(new URL(place).pathname).code).state.view + .kind, + 'place', + ); + + manager.setFollowSubjectProvider(() => ({ + kind: 'aircraft', + id: 'a1b2c3', + offset: OFFSET, + lastKnown: { ...FIX, observedAtMs: NOW_MS }, + })); + const href = manager.buildLink({ nowMs: NOW_MS }); + const code = parseSharePath(new URL(href).pathname).code; + const { state } = decodeShareView(code, { nowMs: NOW_MS }); + assert.deepEqual(state.view, { + kind: 'thing', + entity: { kind: 'aircraft', id: 'a1b2c3' }, + offset: OFFSET, + lastKnown: { ...FIX, observedAtMs: Math.floor(NOW_MS / 1000) * 1000 }, + }); + // The followed aircraft's layer is implied; the camera is derived. + assert.ok(code.length <= 45, `${code.length} characters`); + assert.equal(href, shareLinkUrl(code, 'https://gev.example/')); + manager.destroy(); +}); + +test('the followed entity is read in the share format, and its offset round-trips', () => { + const target = Cesium.Cartesian3.fromDegrees(FIX.lon, FIX.lat, FIX.alt); + const offset = { range: 2400, heading: 30, pitch: -20 }; + const local = followOffsetToEnu(offset); + const cameraPosition = Cesium.Matrix4.multiplyByPoint( + Cesium.Transforms.eastNorthUpToFixedFrame(target), + new Cesium.Cartesian3(local.east, local.north, local.up), + new Cesium.Cartesian3(), + ); + const viewer = { + camera: { positionWC: cameraPosition }, + clock: { currentTime: Cesium.JulianDate.now() }, + trackedEntity: { + gevTrackedId: 'military:ae1234', + gevDisplayPosition: () => target, + }, + }; + const subject = readShareFollowSubject({ viewer, nowMs: NOW_MS }); + assert.equal(subject.kind, 'military_aircraft'); + assert.equal(subject.id, 'ae1234'); + assert.deepEqual(subject.offset, offset); + assert.ok(Math.abs(subject.lastKnown.lat - FIX.lat) < 1e-9); + assert.ok(Math.abs(subject.lastKnown.alt - FIX.alt) < 1e-3); + // A followed entity the share format has no kind for is not a thing view. + viewer.trackedEntity.gevTrackedId = 'local-adsb:abc'; + assert.equal(readShareFollowSubject({ viewer }), null); + // Nor is a camera that follows nothing (a selected vessel is not followed). + viewer.trackedEntity = undefined; + assert.equal(readShareFollowSubject({ viewer }), null); +}); + +test('a shared offset on the far side swings round, never jumping through the entity', () => { + const target = Cesium.Cartesian3.fromDegrees(FIX.lon, FIX.lat, FIX.alt); + const listeners = []; + const looks = []; + const entity = { gevDisplayPosition: () => target }; + const viewer = { + clock: { currentTime: Cesium.JulianDate.now() }, + scene: { + preUpdate: { + addEventListener(fn) { + listeners.push(fn); + return () => listeners.splice(listeners.indexOf(fn), 1); + }, + }, + }, + trackedEntity: entity, + camera: { + transform: Cesium.Transforms.eastNorthUpToFixedFrame(target), + position: new Cesium.Cartesian3(0, -4800, 3300), + lookAtTransform(transform, offset) { + looks.push(Cesium.Cartesian3.clone(offset)); + this.position = Cesium.Cartesian3.clone(offset); + }, + }, + }; + // Looking south at the nose: the opposite side of the app's framing. + const offset = { range: 5800, heading: 180, pitch: -35 }; + applyShareFollowOffset(viewer, entity, offset); + let previous = viewer.camera.position; + for (let frame = 0; frame < 10 && listeners.length; frame += 1) { + listeners[0](); + const now = viewer.camera.position; + // The follow's guard treats a turn of 90° or more as a crossing. + assert.ok(Cesium.Cartesian3.angleBetween(previous, now) < Math.PI / 2); + previous = now; + } + assert.equal(listeners.length, 0, 'it arrives'); + assert.ok(looks.length >= 2, `${looks.length} steps`); + const expected = followOffsetToEnu(offset); + const last = looks.at(-1); + assert.ok(Math.abs(last.x - expected.east) < 1); + assert.ok(Math.abs(last.y - expected.north) < 1); + assert.ok(Math.abs(last.z - expected.up) < 1); +}); + +test('applying a follow offset gives up when the follow ends', () => { + const listeners = []; + const viewer = { + scene: { + preUpdate: { + addEventListener(fn) { + listeners.push(fn); + return () => listeners.splice(listeners.indexOf(fn), 1); + }, + }, + }, + trackedEntity: null, + }; + applyShareFollowOffset(viewer, {}, OFFSET); + listeners[0](); + assert.equal(listeners.length, 0); +}); + +test('long links and hash navigation are unaffected by a share code start', async (t) => { + // A long link still opens from the hash. + const long = openPage(t, 'http://localhost/#v=2&lat=10&lon=20&style=nvg'); + await restore(long); + assert.equal(long.restores[0].style, 'surveillance'); + assert.equal(long.restores[0].shareView, undefined); + long.manager._updateHash(); + assert.equal(long.location.pathname, '/'); + + // After a /share start, navigating to a long link restores it (#1416). + const page = openPage(t, `http://localhost/share/${PLACE_CODE}`); + await restore(page); + page.location.hash = 'v=2&lat=1.0000&lon=2.0000&style=normal'; + page.target.dispatchEvent(new Event('hashchange')); + await flush(); + assert.equal(page.restores.length, 2); + assert.equal(page.restores[1].style, 'normal'); +}); diff --git a/src/ui/shareRestoration.js b/src/ui/shareRestoration.js index 7fd52d917..12f09c18b 100644 --- a/src/ui/shareRestoration.js +++ b/src/ui/shareRestoration.js @@ -1,9 +1,18 @@ import { LayerStateCoordinator } from '../data/layerStateCoordinator.js'; import { stampInitialShareGesture } from '../navigationPolicy.js'; import { canPresentDeferredStatusNotice } from '../loadingFeedback.js'; -import { t, msg } from '../i18n/index.js'; +import { t, msg, formatDate } from '../i18n/index.js'; +import { SHARE_VIEW_ENTITY_KINDS } from '../share/follow.js'; import { UiLifetime } from './uiLifetime.js'; +/** How long a shared vessel may take to appear in the vessel feed. */ +export const SHARED_VESSEL_WAIT_MS = 120_000; +const SHARED_VESSEL_POLL_MS = 2_000; +const DAY_MS = 86_400_000; +const ENTITY_BY_KIND = new Map( + SHARE_VIEW_ENTITY_KINDS.map((entry) => [entry.kind, entry]), +); + // One ready-made sentence per restore policy (SHARE_TRACKING_RESTORE_POLICIES // label) and outcome: a label dropped into a template cannot take the case or // article the sentence needs in every language. The generic template stays @@ -51,6 +60,36 @@ export function shareTrackingRestoreNotice(result) { : t('Shared {subject} is unavailable', { subject }); } +/** + * The notice for a shared entity that is not reporting: the view shows where + * it was when the link was made. + * @param {{kind: string, id: string}} entity + * @param {number|null} observedAtMs + * @param {number} [nowMs] + * @returns {string} + */ +export function sharedFixNotice(entity, observedAtMs, nowMs = Date.now()) { + // ICAO addresses read as they are written on the aircraft: upper case. + const id = + ENTITY_BY_KIND.get(entity.kind)?.idForm === 'hex24' + ? String(entity.id).toUpperCase() + : String(entity.id); + if (!Number.isFinite(observedAtMs)) + return t("{id} isn't reporting now — showing where it was last seen", { + id, + }); + const time = formatDate( + observedAtMs, + nowMs - observedAtMs < DAY_MS + ? { hour: '2-digit', minute: '2-digit' } + : { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }, + ); + return t("{id} isn't reporting now — showing where it was at {time}", { + id, + time, + }); +} + /** Own initial share restoration, durable layer state and restoration notices. */ export class ShareRestoration { constructor({ @@ -61,6 +100,7 @@ export class ShareRestoration { showStatus, feedback, updateFeedback, + applyFollowOffset = null, }) { Object.assign(this, { viewer, @@ -70,6 +110,9 @@ export class ShareRestoration { showStatus, feedback, updateFeedback, + // (entity, offset) => disposer: frame a followed entity from a shared + // follow offset (shareFollow.js). + applyFollowOffset, }); this._lifetime = new UiLifetime(); this._disposed = false; @@ -84,6 +127,10 @@ export class ShareRestoration { // settles later must neither apply follow-up work nor release URL writes. this._shareRestoreGeneration = 0; this._navigatedShareRestoreActive = false; + this._stopFollowOffset = null; + // Bumped to abandon the wait for a shared vessel. + this._sharedVesselGeneration = 0; + this._sharedVesselPending = false; } attachLinks(shareLinkManager) { this.shareLinkManager = shareLinkManager; @@ -121,11 +168,21 @@ export class ShareRestoration { }, 1500); } else { this.syncShareState(); + if (this.shareLinkManager?.rejectedShareCode) + this._presentAfterStartupCover( + t("This share link can't be opened"), + ++this._shareTrackingNoticeGeneration, + ); } // A recipient can orbit before or during the delayed share flight. That // gesture keeps ordinary layer state but revokes the passive base camera // and selected-subject Follow so delayed work cannot seize navigation. this._initialShareGestureHandler = () => { + // The recipient taking the camera also ends the wait for a vessel. + if (this._sharedVesselPending) { + this._sharedVesselGeneration += 1; + this._sharedVesselPending = false; + } if ( this._disposed || !this._hasShareState || @@ -191,6 +248,10 @@ export class ShareRestoration { }; if (!current()) return; this.shareLinkManager.completeInitialRestore(state.restoreToken); + // Selecting a vessel leaves the camera alone, so it does not depend on + // the shared flight having landed. + const vessel = this._sharedVessel(state); + if (vessel) void this._restoreSharedVessel(vessel, current); settle({ status: 'settled', share, layers, tracking }); } catch (error) { if (!current()) return; @@ -301,7 +362,79 @@ export class ShareRestoration { }); } } + /** The shared vessel a share's thing view selects, if it names one. */ + _sharedVessel(state) { + const view = state?.shareView; + return view?.kind === 'thing' && view.entity.kind === 'vessel' + ? view + : null; + } + + /** + * Select a shared vessel once the vessel feed has it. Vessels are selected, + * not followed: the camera is already at the shared view. If the vessel does + * not appear in time, the view stays where it was when the link was made. + */ + async _restoreSharedVessel(view, current) { + const generation = ++this._sharedVesselGeneration; + const live = () => current() && generation === this._sharedVesselGeneration; + const deadline = Date.now() + SHARED_VESSEL_WAIT_MS; + const { id } = view.entity; + const { layerId } = ENTITY_BY_KIND.get(view.entity.kind); + this._sharedVesselPending = true; + try { + while (live()) { + const vessels = this._dataManager?.layers?.get?.(layerId)?.module; + if (vessels?.hasContact?.(id) && vessels.selectById(id)) return true; + if (Date.now() >= deadline) break; + await new Promise((resolve) => + this._lifetime.timeout(resolve, SHARED_VESSEL_POLL_MS), + ); + } + } finally { + if (generation === this._sharedVesselGeneration) + this._sharedVesselPending = false; + } + if (!live()) return false; + this._presentAfterStartupCover( + view.lastKnown + ? sharedFixNotice(view.entity, view.lastKnown.observedAtMs) + : t('Shared vessel is unavailable'), + ++this._shareTrackingNoticeGeneration, + ); + return false; + } + + /** The current share's thing view when `result` is about its entity. */ + _sharedThingFor(result) { + const view = this._initialShareState?.shareView; + if (view?.kind !== 'thing') return null; + const entry = ENTITY_BY_KIND.get(view.entity.kind); + return entry?.tracking === result?.layerId && + String(view.entity.id) === String(result?.targetId) + ? view + : null; + } + + /** Frame a shared entity the camera now follows from the shared offset. */ + _applySharedFollowOffset(result) { + const view = this._sharedThingFor(result); + const entity = this.viewer?.trackedEntity; + // Only a link made while following carries a real offset (with the fix); + // a long link's follow keeps the app's own framing. + if ( + !view?.lastKnown || + String(entity?.gevTrackedId || '') !== + `${result.layerId}:${result.targetId}` + ) + return; + this._stopFollowOffset?.(); + this._stopFollowOffset = this.applyFollowOffset?.(entity, view.offset); + } + cancelSelection() { + // An explicit navigation also ends the wait for a shared vessel. + this._sharedVesselGeneration += 1; if ( this._hasShareState && this._resolveInitialShareRestore && @@ -347,17 +480,39 @@ export class ShareRestoration { this.updateFeedback(); } } - if ( - result.classification === 'followed' || - result.classification === 'cancelled' - ) + if (result.classification === 'followed') { + this._applySharedFollowOffset(result); return; + } + if (result.classification === 'cancelled') return; // A stale terminal result must never replace a newer target's acquisition. if (this._shareTrackingAcquiringKey) return; const noticeGeneration = ownsAcquiringNotice ? this._shareTrackingNoticeGeneration : ++this._shareTrackingNoticeGeneration; - const message = shareTrackingRestoreNotice(result); + // A share link that knows where the entity was says so; the view is + // already there. + const fix = this._sharedThingFor(result)?.lastKnown; + const message = fix + ? sharedFixNotice( + this._initialShareState.shareView.entity, + fix.observedAtMs, + ) + : shareTrackingRestoreNotice(result); + if (this._resolveInitialShareRestore) { + void this.initialRestorePromise.then(() => + this._presentAfterStartupCover(message, noticeGeneration), + ); + return; + } + this._presentAfterStartupCover(message, noticeGeneration); + } + + /** + * Show a status notice once the start-up cover is gone, unless a newer + * notice (generation) has taken its place. + */ + _presentAfterStartupCover(message, noticeGeneration) { const showAfterStartupCover = () => { this._lifetime.frame(() => { if ( @@ -399,10 +554,6 @@ export class ShareRestoration { fallbackTimer = this._lifetime.timeout(showOnce, 1000); }); }; - if (this._resolveInitialShareRestore) { - void this.initialRestorePromise.then(showAfterStartupCover); - return; - } showAfterStartupCover(); } _settleInitialShareRestore(result) { @@ -419,6 +570,9 @@ export class ShareRestoration { this._disposed = true; this._shareTrackingNoticeGeneration += 1; this._shareTrackingAcquiringKey = null; + this._sharedVesselGeneration += 1; + this._stopFollowOffset?.(); + this._stopFollowOffset = null; this._layerStateCoordinator?.destroy(); this._layerStateCoordinator = null; this._layerStateRestorePromise = null;