feat(share): short /share/<code> links with a compact versioned view format (#1418)

* feat(share): add compact versioned share-view codec

Add a standalone codec for `#s=<base64url>` 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/<code> links

The share button now makes `<app>/share/<code>` 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
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-10-26 15:31:51 -07:00
1 parent fefd5cdcf1
commit da02fd8645
28 files changed
+5605 -97

No files matched your search

+308
View File
@@ -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();
});