Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions app/changelog.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
27 changes: 27 additions & 0 deletions app/src/__tests__/announce.test.js
Original file line number Diff line number Diff line change
@@ -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)
})
})
29 changes: 28 additions & 1 deletion app/src/__tests__/feed.test.js
Original file line number Diff line number Diff line change
@@ -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 })

Expand Down Expand Up @@ -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'])
})
})
33 changes: 32 additions & 1 deletion app/src/__tests__/settings.test.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
})
})
29 changes: 29 additions & 0 deletions app/src/announce.js
Original file line number Diff line number Diff line change
@@ -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
}
59 changes: 57 additions & 2 deletions app/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1638,6 +1672,14 @@ function buildSettingsSheet() {
</select>
</label>
</div>
<div class="ss-radio-section">
<h3>Identity</h3>
<label class="ss-check-row" id="ss-row-share-name">
<input type="checkbox" id="ss-share-name" />
<span>Share my node name</span>
</label>
<p class="ss-hint">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.</p>
</div>
<div class="ss-theme-row">
<span>Theme</span>
<div id="ss-theme" class="ss-seg" role="group" aria-label="Theme">
Expand Down Expand Up @@ -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)
Expand Down
19 changes: 19 additions & 0 deletions app/src/feed.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 11 additions & 1 deletion app/src/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}

Expand Down
5 changes: 5 additions & 0 deletions app/src/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
7 changes: 7 additions & 0 deletions web/changelog.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading