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",