From 58ac96e33dded5101070a9da05e666d9b3ecaf62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20H=C3=A4gele?= Date: Fri, 4 Sep 2026 16:08:07 +0200 Subject: [PATCH] feat(app): add Share my node name, off by default, sending a zero-hop advert each cycle a companion is the target The hunter's companion never announced itself (#576): the app sends Discover broadcasts and trace-pings, which carry no identity, and nothing else. MeshCore firmware answers a request only from a sender it can look up in its contact list, and a node adds us to that list when it hears our advert. So a companion that has not heard us cannot answer the telemetry request #553 wants to send. An advert is also the first time the app would transmit who the hunter is, so it is a setting, off by default: Share my node name, in a new Identity section of Settings, with the plain words under it. With it on, every auto-discover cycle that has a companion among the selected targets sends one CMD_SEND_SELF_ADVERT (7) with the route byte at 0, which the firmware sends zero-hop, never a flood. Per cycle rather than once at switch-on, because the target can be out of range at that moment and it has to hear us before it can answer. A repeater target keeps its trace-ping and gets no advert: selectedCompanionIds is the selection minus the repeater reading, so the two never disagree about one node. The FAB pulses and the tx cue sounds, as for every frame that goes out. The setting lights the settings dot like every other non-default. What the advert carries is the companion's own: its public key and name, and a position only if the owner set the location policy in the MeshCore app. Verified: app 906 unit tests, build and eslint clean; web 509 (the changelog copies). Eight mutation checks over the two rounds, each red on one test. Browser at 360 with a stub transport: nothing selected sends no advert; a companion selected sends one [7,0] per cycle; the same node read as a repeater gets a trace-ping and no advert; the setting off, or the selection cleared, sends none; the setting survives a reload. Closes #576 Co-Authored-By: Claude Fable 5.1 --- app/changelog.json | 7 ++++ app/src/__tests__/announce.test.js | 27 ++++++++++++++ app/src/__tests__/feed.test.js | 29 ++++++++++++++- app/src/__tests__/settings.test.js | 33 ++++++++++++++++- app/src/announce.js | 29 +++++++++++++++ app/src/app.js | 59 +++++++++++++++++++++++++++++- app/src/feed.js | 19 ++++++++++ app/src/settings.js | 12 +++++- app/src/styles/app.css | 5 +++ web/changelog.json | 7 ++++ 10 files changed, 222 insertions(+), 5 deletions(-) create mode 100644 app/src/__tests__/announce.test.js create mode 100644 app/src/announce.js diff --git a/app/changelog.json b/app/changelog.json index 31b33e0..f85c34f 100644 --- a/app/changelog.json +++ b/app/changelog.json @@ -1,4 +1,11 @@ [ + { + "id": "2026-09-04-share-my-node-name", + "date": "2026-09-04", + "where": "app", + "title": "Share my node name, off unless you turn it on", + "body": "A new setting shares your companion's name and key with nodes in direct range, each auto-discover cycle while a companion is your target. Off, the app never transmits who you are." + }, { "id": "2026-08-29-theme-follows-your-device", "date": "2026-08-29", diff --git a/app/src/__tests__/announce.test.js b/app/src/__tests__/announce.test.js new file mode 100644 index 0000000..c6a4706 --- /dev/null +++ b/app/src/__tests__/announce.test.js @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest' +import { buildSelfAdvertFrame, announceThisCycle } from '../announce.js' + +// The frame is CMD_SEND_SELF_ADVERT (7) with the route byte at 0, which the +// firmware reads as zero-hop (examples/companion_radio/MyMesh.cpp:1258, "1 = +// flood, 0 = zero hop"). A 1 there floods the mesh, so the byte is the test. +describe('buildSelfAdvertFrame', () => { + it('asks the companion for one zero-hop advert, never a flood', () => { + expect([...buildSelfAdvertFrame()]).toEqual([7, 0]) + }) +}) + +// The advert rides the auto-ping cycle, and only while a selected target is a +// companion: that is the node that has to hear us before it can answer. With +// the setting off, or nothing selected that needs it, no cycle carries one. +describe('announceThisCycle', () => { + it('is true only with the setting on, a companion connected, and a companion target', () => { + expect(announceThisCycle({ shareName: true, connected: true, companionTargets: 1 })).toBe(true) + expect(announceThisCycle({ shareName: false, connected: true, companionTargets: 1 })).toBe(false) + expect(announceThisCycle({ shareName: true, connected: false, companionTargets: 1 })).toBe(false) + expect(announceThisCycle({ shareName: true, connected: true, companionTargets: 0 })).toBe(false) + }) + // Off by default means a missing or malformed value must read as off. + it('treats anything but an explicit true as off', () => { + for (const v of [undefined, null, 1, '1', 'true']) expect(announceThisCycle({ shareName: v, connected: true, companionTargets: 2 }), String(v)).toBe(false) + }) +}) diff --git a/app/src/__tests__/feed.test.js b/app/src/__tests__/feed.test.js index ad72eac..235144a 100644 --- a/app/src/__tests__/feed.test.js +++ b/app/src/__tests__/feed.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { relTime, senderList, topSenders, targetParts, selectedRepeaterIds, clusterKey, expandSelection, selectionKeyFor, idPrefix, matchesTarget, heardRepeaterIds } from '../feed.js' +import { relTime, senderList, topSenders, targetParts, selectedRepeaterIds, clusterKey, expandSelection, selectionKeyFor, idPrefix, matchesTarget, heardRepeaterIds, selectedCompanionIds } from '../feed.js' const rec = (o) => ({ sender_kind: 'channel_name', sender_id: 'Spammer', rx_at: '2026-06-29T10:00:00Z', ...o }) @@ -602,3 +602,30 @@ describe('heardRepeaterIds (#479)', () => { expect(heardRepeaterIds(rows)).toEqual(['ab12cd']) }) }) + +// Which selected targets get the self-advert each cycle (#576): the ones a +// trace-ping cannot reach. A companion answers only a sender it has as a +// contact, and it adds us when it hears our advert; a repeater needs none of +// that, so it stays with the trace-ping. Defined as the selection minus the +// repeater reading, so the two never disagree about one node. +describe('selectedCompanionIds', () => { + const rec = (o) => ({ rx_at: '2026-06-29T10:00:00Z', sender_kind: 'advert_pubkey', ...o }) + it('returns the selected ids whose node does not behave as a repeater', () => { + const rows = [rec({ sender_id: 'aa', sender_role: 'ChatNode' }), rec({ sender_id: 'bb', sender_role: 'Repeater' })] + expect(selectedCompanionIds(rows, new Set(['aa', 'bb']))).toEqual(['aa']) + }) + it('leaves out a node only ever heard relaying, and one that answered a trace', () => { + const rows = [rec({ sender_id: 'cc', sender_kind: 'relay', sender_role: null }), rec({ sender_id: 'dd', sender_kind: 'trace_reply', sender_role: null })] + expect(selectedCompanionIds(rows, new Set(['cc', 'dd']))).toEqual([]) + }) + it('is empty with nothing selected, or with a selection nothing was heard from', () => { + const rows = [rec({ sender_id: 'aa', sender_role: 'ChatNode' })] + expect(selectedCompanionIds(rows, new Set())).toEqual([]) + expect(selectedCompanionIds(rows, null)).toEqual([]) + expect(selectedCompanionIds(rows, new Set(['zz']))).toEqual([]) + }) + it('lists each node once, however many receptions it has', () => { + const rows = [rec({ sender_id: 'AA', sender_role: 'ChatNode' }), rec({ sender_id: 'aa', sender_role: 'ChatNode', rx_at: '2026-06-29T10:01:00Z' })] + expect(selectedCompanionIds(rows, new Set(['aa']))).toEqual(['aa']) + }) +}) diff --git a/app/src/__tests__/settings.test.js b/app/src/__tests__/settings.test.js index 7706683..f428f34 100644 --- a/app/src/__tests__/settings.test.js +++ b/app/src/__tests__/settings.test.js @@ -1,5 +1,5 @@ import { describe, it, expect, afterEach, vi } from 'vitest' -import { isSettingsActive, initialSettingsTab, loadAttenuator, loadSoundMode, loadViewIndex, loadChangelogSeen, saveChangelogSeen, loadLegacyChangelogAck, loadThemePref } from '../settings.js' +import { isSettingsActive, initialSettingsTab, loadAttenuator, loadSoundMode, loadViewIndex, loadChangelogSeen, saveChangelogSeen, loadLegacyChangelogAck, loadThemePref, loadShareName } from '../settings.js' // A storage stub whose getItem throws, standing in for the contexts where // localStorage access raises SecurityError (Safari with cookies blocked, a @@ -173,3 +173,34 @@ describe('changelog acknowledgement (#284, #422)', () => { expect(() => saveChangelogSeen('2026-08-22-b')).not.toThrow() }) }) + +// The settings dot says "something behind this button is not at its default". +// Sharing the node name is exactly that, and it is the one setting that +// transmits, so it must reach the dot (#576). +describe('isSettingsActive lights for Share my node name', () => { + it('is true with the name shared and everything else at default', () => { + expect(isSettingsActive({ attenuatorDb: 0, unseenChangelog: false, shareName: true })).toBe(true) + expect(isSettingsActive({ attenuatorDb: 0, unseenChangelog: false, shareName: false })).toBe(false) + }) +}) + +// Share my node name (#576): the first setting that puts the hunter's own +// identity on air, so it is off unless the stored value says on, exactly. +describe('loadShareName', () => { + it('is on only for the stored on-value', () => { + vi.stubGlobal('localStorage', storageWith({ 'core-hunter-share-name': '1' })) + expect(loadShareName()).toBe(true) + }) + it('is off for a missing or corrupt value', () => { + vi.stubGlobal('localStorage', storageWith({})) + expect(loadShareName()).toBe(false) + for (const v of ['0', 'true', 'yes', 'on']) { + vi.stubGlobal('localStorage', storageWith({ 'core-hunter-share-name': v })) + expect(loadShareName(), v).toBe(false) + } + }) + it('is off when storage throws', () => { + vi.stubGlobal('localStorage', throwingStorage()) + expect(loadShareName()).toBe(false) + }) +}) diff --git a/app/src/announce.js b/app/src/announce.js new file mode 100644 index 0000000..23307a1 --- /dev/null +++ b/app/src/announce.js @@ -0,0 +1,29 @@ +// Share my node name (#576): the one frame that puts the hunter's own identity +// on air. The companion sends its self-advert: its public key and name, plus a +// position only if its owner set the location policy in the MeshCore app; this +// module adds nothing to it. Off by default, and it rides the auto-ping cycle +// only while a selected target is a companion, the node that has to hear us. +// +// Why it exists: MeshCore firmware answers a request only from a sender it can +// look up in its contact list (src/Mesh.cpp:150-156, the shared secret comes +// from the contact), and a node adds us to that list when it hears our advert +// (src/helpers/BaseChatMesh.cpp:151-176). Without it, #553's telemetry request +// reaches a companion that cannot decrypt it. + +// examples/companion_radio/MyMesh.cpp:1250-1268. Byte 1 selects the route: +// 1 = flood, 0 (or absent) = zero hop. Always 0 here: the advert is for the +// nodes that can hear us directly, which are the ones we can hunt. +export const CMD_SEND_SELF_ADVERT = 7 +export const ADVERT_ZERO_HOP = 0 + +export function buildSelfAdvertFrame() { + return Uint8Array.from([CMD_SEND_SELF_ADVERT, ADVERT_ZERO_HOP]) +} + +// announceThisCycle: does this auto-ping cycle carry the advert? Only with the +// setting explicitly on, a companion to send it through, and at least one +// selected target that needs it (selectedCompanionIds). Anything but an exact +// true is off, since off is the default this setting protects. +export function announceThisCycle({ shareName, connected, companionTargets } = {}) { + return shareName === true && connected === true && (Number(companionTargets) || 0) > 0 +} diff --git a/app/src/app.js b/app/src/app.js index 969edd0..1a7f88e 100644 --- a/app/src/app.js +++ b/app/src/app.js @@ -30,7 +30,8 @@ import { createHuntMap } from './huntmap.js' import { VIEW_STATES, VIEW_LABELS, nextViewIndex, viewKey } from './maplayers.js' import { makeFilter, isFilterActive, DEFAULT_FILTER, FILTER_PACKET_TYPES, SENDER_ID_CLASSES } from './filters.js' import { connectButton, connectFailureMessage } from './connectstate.js' -import { isSettingsActive, initialSettingsTab, loadAttenuator, loadSoundMode, loadViewIndex, loadChangelogSeen, saveChangelogSeen, loadLegacyChangelogAck, loadThemePref } from './settings.js' +import { isSettingsActive, initialSettingsTab, loadAttenuator, loadSoundMode, loadViewIndex, loadChangelogSeen, saveChangelogSeen, loadLegacyChangelogAck, loadThemePref, loadShareName } from './settings.js' +import { buildSelfAdvertFrame, announceThisCycle } from './announce.js' import { THEME_PREFS, resolveTheme } from './theme.js' import { whereLabel, hasUnseenEntries, unseenEntryCount, migratedSeenId } from './changelog.js' import { sinceLabel } from './elapsed.js' @@ -39,7 +40,7 @@ import { createReceptionLog } from './receptionlog.js' import { createTargetList } from './targetlist.js' import { resolveName, cachedName, resolvableKey } from './names.js' import { buildDiscoverFrame, buildTracePathFrame } from './discover.js' -import { selectedRepeaterIds, heardRepeaterIds, senderList, expandSelection, idPrefix, selectionKeyFor } from './feed.js' +import { selectedRepeaterIds, selectedCompanionIds, heardRepeaterIds, senderList, expandSelection, idPrefix, selectionKeyFor } from './feed.js' import { shouldAutoFire, staggerTargets } from './autoping.js' import { nextSweepBatch, noteAsk } from './sweep.js' import { createWakeLock } from './wakelock.js' @@ -81,6 +82,15 @@ function saveAttenuator(db) { try { localStorage.setItem('core-hunter-attenuator', String(db)) } catch (_) {} } +// Share my node name (#576). Stored as '1' or removed, so loadShareName's +// exact-match read has one on-value and everything else is off. +function saveShareName(on) { + try { + if (on) localStorage.setItem('core-hunter-share-name', '1') + else localStorage.removeItem('core-hunter-share-name') + } catch (_) {} +} + // Sound mode (#145): off / rxtx / full, cycled by the sound FAB. Persisted // like the attenuator; loader lives in settings.js (guarded + unit-tested, #338). function saveSoundMode(mode) { @@ -165,6 +175,8 @@ const state = { published: new Set(), ignore: loadIgnore(), attenuatorDb: loadAttenuator(), + // Share my node name (#576): off by default, the hunter's own decision. + shareName: loadShareName(), soundMode: loadSoundMode(), themePref: loadThemePref(), // Unread release notes (#421). Lives on state so the settings button's dot @@ -1033,6 +1045,11 @@ function autoPingTick() { sendDiscover() pulseDiscoverBtn() sound.txBlip('discover') // audio twin of the FAB pulse (#145) + // With Share my node name on, a cycle that has a companion as target also + // carries our advert (#576): that is the node that has to hear us before it + // can answer, and one advert at switch-on could be sent while it is out of + // range. Zero-hop, so it costs the mesh nothing beyond this one airtime. + if (announceThisCycle({ shareName: state.shareName, connected: state.connected, companionTargets: selectedCompanionTargets().length })) sendSelfAdvert() // Each staggered trace-ping is also a real transmission — pulse the FAB and // sound the cue for it too, but only if the ping actually succeeds (#254). // The tx cue follows the same rule as the pulse: it must mean "a frame went @@ -1091,6 +1108,23 @@ function toggleAutoPing() { updateDiscoverBtnVisual() } +// The selected targets a trace-ping cannot reach (#576): companions, which +// answer only a sender they have as a contact. +function selectedCompanionTargets() { + const selected = selectedSet() + if (!selected) return [] + return selectedCompanionIds(state.lastRows, selected) +} + +// sendSelfAdvert asks the companion for one zero-hop advert. A real frame +// going out, so it gets the FAB pulse and the tx cue like every other one. +function sendSelfAdvert() { + if (!state.connected || !state.transport) return + state.transport.send(buildSelfAdvertFrame()).catch(() => {}) + pulseDiscoverBtn() + sound.txBlip('discover') +} + // --------------------------------------------------------------------------- // Connect / disconnect // --------------------------------------------------------------------------- @@ -1638,6 +1672,14 @@ function buildSettingsSheet() { +
+

Identity

+ +

Shares your companion's name and key with nodes in direct range, once per auto-discover cycle while a companion is your target. Off: the app never transmits who you are.

+
Theme
@@ -1738,6 +1780,19 @@ function buildSettingsSheet() { }) refreshConnState() + // Share my node name (#576): a checkbox, saved on change, and the row and + // the settings dot both say when it is on. + const share = el('ss-share-name') + share.checked = state.shareName + const syncShareRow = () => el('ss-row-share-name').classList.toggle('active', state.shareName) + syncShareRow() + share.addEventListener('change', () => { + state.shareName = share.checked + saveShareName(state.shareName) + syncShareRow() + refreshSettingsIndicator() + }) + const atten = el('ss-atten') atten.value = String(state.attenuatorDb) const syncAttenRow = () => el('ss-row-atten').classList.toggle('active', (Number(atten.value) || 0) !== 0) diff --git a/app/src/feed.js b/app/src/feed.js index 76f6d3f..77baa40 100644 --- a/app/src/feed.js +++ b/app/src/feed.js @@ -251,6 +251,25 @@ export function selectedRepeaterIds(records, selectedIds) { return repeaterIds(records, (id) => selectedIds.has(id)) } +// selectedCompanionIds is the other half of the selection (#576): the targets +// a trace-ping cannot reach. A companion answers only a sender it has as a +// contact, and it adds us when it hears our advert, so these are the nodes the +// self-advert is for each cycle. Defined as the selection minus the repeater +// reading, so the two readings never disagree about one node; a room server or +// a sensor lands here too, which is right, since neither forwards a trace. +export function selectedCompanionIds(records, selectedIds) { + if (!selectedIds || selectedIds.size === 0) return [] + const repeaters = new Set(selectedRepeaterIds(records, selectedIds).map((id) => id.slice(0, 2))) + const out = [] + for (const r of records || []) { + if (r.sender_id == null) continue + const id = String(r.sender_id).toLowerCase() + if (!selectedIds.has(id) || out.includes(id) || repeaters.has(id.slice(0, 2))) continue + out.push(id) + } + return out +} + // heardRepeaterIds is the same reading over everything heard rather than over a // selection: the nodes worth trace-pinging when no target is chosen (#479). Same // per-frame collapse, so the sweep never spends two transmissions on one frame. diff --git a/app/src/settings.js b/app/src/settings.js index 48ba000..aba4f72 100644 --- a/app/src/settings.js +++ b/app/src/settings.js @@ -45,6 +45,13 @@ export function loadThemePref() { return THEME_PREFS.includes(v) ? v : 'system' } +// Share my node name (#576): the first setting that puts the hunter's own +// identity on air. Off unless the stored value says on, exactly: a missing or +// malformed slot must never read as "share". +export function loadShareName() { + return readStored('core-hunter-share-name') === '1' +} + // Index into VIEW_STATES for the persisted view (#258). No/corrupt stored // value falls back to both/2D — the app's cold default before that merge // (huntmap.js's own mode/mode3D defaults), not index 0. @@ -86,9 +93,12 @@ export function loadLegacyChangelogAck() { // button reads as noise, and the two mean the same thing to the person looking // at it — there is something behind this button you have not dealt with. What // it is, is one tap away, and the tab carries its own dot to say which. -export function isSettingsActive({ attenuatorDb, unseenChangelog } = {}) { +export function isSettingsActive({ attenuatorDb, unseenChangelog, shareName } = {}) { if (attenuatorDb) return true if (unseenChangelog) return true + // Sharing the node name is a non-default that transmits (#576), so it is + // exactly what the dot is for. + if (shareName === true) return true return false } diff --git a/app/src/styles/app.css b/app/src/styles/app.css index 729c654..6116a91 100644 --- a/app/src/styles/app.css +++ b/app/src/styles/app.css @@ -398,6 +398,11 @@ html, body { margin: 0; height: 100%; background: var(--ch-bg); color: var(--ch- .ss-acc-msg.ok { color: var(--ch-sig-cool); } .ss-radio-section { margin-top: 16px; } .ss-radio-row { display: flex; align-items: center; gap: 8px; margin-top: 8px; font-size: 14px; color: var(--ch-text); } +/* A checkbox row in Settings (#576): the list-row shape, checkbox first, and + the label in accent while the setting is on, like the select rows. */ +.ss-check-row { display: flex; align-items: center; gap: 8px; margin-top: 8px; font-size: 14px; color: var(--ch-text); } +.ss-check-row.active > span { color: var(--ch-accent); } +.ss-hint { margin: 4px 0 0; font-size: 12px; line-height: 1.4; color: var(--ch-muted); } .ss-radio-row select { padding: 4px 8px; border-radius: 6px; border: 1px solid var(--ch-muted); background: transparent; color: var(--ch-text); font-size: 14px; } .ss-version-row { display: flex; align-items: center; gap: 8px; } diff --git a/web/changelog.json b/web/changelog.json index 31b33e0..f85c34f 100644 --- a/web/changelog.json +++ b/web/changelog.json @@ -1,4 +1,11 @@ [ + { + "id": "2026-09-04-share-my-node-name", + "date": "2026-09-04", + "where": "app", + "title": "Share my node name, off unless you turn it on", + "body": "A new setting shares your companion's name and key with nodes in direct range, each auto-discover cycle while a companion is your target. Off, the app never transmits who you are." + }, { "id": "2026-08-29-theme-follows-your-device", "date": "2026-08-29",