Commit Graph
100 Commits
Author SHA1 Message Date
MarekWoandClaude Opus 5 b40a28c3b4 docs(android): describe the path for releasing a new version
Closes the last open [CLAUDE] item in the Play release plan. The section only
covered the .apk in this repository; the app has been on Google Play since
2026-09-05 and the two have to go out together or they drift apart. Adds what
Play needs on top - an .aab rather than an .apk, a versionCode that has not been
used, release notes per listing language, and the declaration a foreground
service always triggers - plus the promote-from-library shortcut that made the
first production review take hours instead of days.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 10:50:46 +02:00
MarekWoandClaude Opus 5 fefa43d30d feat(android): background notifications and remembered servers
Two changes to the Android wrapper, shipping together as app version 2.1
(versionCode 6).

Notifications stopped arriving a few minutes after the app was backgrounded,
and no setting on the phone could prevent it: Android freezes a process with
nothing in the foreground, and a frozen process runs no JavaScript and holds
no socket. MeshWatchService is a foreground service of type remoteMessaging
that does no work of its own - it exists so Android leaves the process alone.
Every notification is still built by the mc-webui page, so mutes, blocked
senders, channel names, translations and deep links stay in one place rather
than being duplicated in Kotlin.

The service follows mc-webui's own notification toggle, read from localStorage
by the shim rather than asked of the page, because the wrapper connects to
whichever server its user typed in - very often an older mc-webui. The shim
also wraps Storage.prototype.setItem, since localStorage fires no event in the
document that wrote it. Android's own switches have the last word, so a revoked
permission or a silenced channel stops the service too.

setRendererPriorityPolicy goes with it: the page renders in a process of its
own, and WebView waives that process's priority once the view stops being
visible, so a service saving only the host process would still lose the
renderer under memory pressure.

Separately, the address screen now lists the servers reached before, so running
more than one instance no longer means retyping the whole address to switch.
Only addresses that answered are listed, which keeps typos out.

Also replaces the deprecated setDecorFitsSystemWindows with enableEdgeToEdge,
which is what Play Console has been flagging twice. Its default navigation-bar
scrim is overridden, or it would paint over the strip the wrapper colours to
match the page below Android 10.

Docs, the privacy policy (both languages) and the app-vs-PWA comparison are
updated: three of them said the app ran nothing in the background.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 10:45:26 +02:00
MarekWoandClaude Opus 5 2afa9c182b chore: open 2.16.0 development
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 15:03:19 +02:00
MarekWoandClaude Opus 5 2d0c1bd01b chore(release): finalize 2.15.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 15:02:54 +02:00
MarekWoandClaude Opus 5 592924951d docs: the Android app is on Google Play
The wrapper was published to the Play Store today, so three places that told
readers it was not distributed there are now wrong: the Android guide, the
user guide and the README.

Each now leads with the Store and keeps the .apk as the deliberate second
route, with the fact that matters to existing users stated where they will
look for it: both builds carry the same signing key, so installing from Play
updates a repository build in place and keeps the server address.

Historical whatsnew entries are left as written; the change is announced in a
new Unreleased entry instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 14:50:28 +02:00
MarekWo 716be82f18 Merge branch 'main' into dev 2026-09-02 22:07:52 +02:00
MarekWoandClaude Opus 5 4f94e877f1 docs: note the sudo case in the GitHub HTTP/2 workaround
--global writes to the config of whoever runs it, so a user updating with sudo was still prompted after applying the fix. Confirmed by a user on Raspberry Pi OS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 21:57:19 +02:00
MarekWoandClaude Opus 5 6dc2fc0a0e docs: note the sudo case in the GitHub HTTP/2 workaround
--global writes to the config of whoever runs it, so a user updating with sudo was still prompted after applying the fix. Confirmed by a user on Raspberry Pi OS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 21:57:07 +02:00
MarekWoandClaude Opus 5 64209752bc docs: banner the GitHub HTTP/2 update block at the top of the README
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 21:21:32 +02:00
MarekWoandClaude Opus 5 01efc396dc docs: banner the GitHub HTTP/2 update block at the top of the README
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 21:21:14 +02:00
MarekWoandClaude Opus 5 636dce70a1 chore: open 2.15.0 development
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 21:12:54 +02:00
MarekWo 8989463c08 Merge branch 'dev' 2026-09-02 21:12:31 +02:00
MarekWoandClaude Opus 5 8403da3ca7 chore(release): finalize 2.14.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 21:12:22 +02:00
MarekWoandClaude Opus 5 4df984b2e0 fix(update): stop mcupdate asking for a GitHub username
GitHub's HTTP/2 front end began answering the anonymous git-upload-pack
POST with 401 for the git 2.39 / libcurl 7.88 build that ships with
Debian 12. Git reads any 401 as a credential request, so update.sh sat
on "Username for 'https://github.com':" against a public repo, on every
server at once, with nothing changed locally.

Pull with http.version=HTTP/1.1, which GitHub still serves anonymously,
and set GIT_TERMINAL_PROMPT=0 so a genuine auth failure errors out
instead of blocking on stdin forever. Documented in troubleshooting,
README and whatsnew, including the one-off git config for installs too
old to carry the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 21:09:41 +02:00
MarekWoandClaude Opus 5 72205dd145 chore: ignore the tooling-generated AGENTS.md / .agents mirrors
Both are copies of files already in the untracked .claude/ directory --
AGENTS.md of .claude/CLAUDE.md, .agents/skills/ of .claude/skills/ --
written by the agent tooling, not by hand. They were never tracked, and
a `git add -A` sweeping them into an unrelated commit is exactly what
this prevents.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 06:15:16 +02:00
MarekWoandClaude Opus 5 67b5e02d96 feat(repeaters): read settings on demand, remember what was read
Expanding a Settings section fired one `get` per field, each a full mesh
round-trip. Seven questions to a repeater four hops away, answered one at
a time, to change the one field you came for -- and often a batch that
timed out halfway and left the section blank.

Nothing is read until asked for now:

- every field has its own read button beside the control (same row, so
  no extra line per field), and `Read all fields` at the top of the
  section still does the whole batch
- GET /settings takes an optional `fields=` subset so a single field is a
  single round-trip; the section still defines the order
- reads are queued client-side. The device already serializes CLI traffic
  behind one lock, so parallel requests would only tie up server threads
  waiting for it
- values are remembered per repeater in localStorage, so a section opens
  with the last readings instead of empty boxes. A remembered value is a
  usable baseline -- Apply writes an absolute value, so editing from a
  stale reading still sets what was typed -- and is marked with a clock
  per field plus a note at the foot of the section saying when it was
  read. Apply and unsupported replies update the cache too
- a failed read no longer wipes what is on screen: the remembered value
  stays, still editable, still marked
- field hints moved from a text block under the control into an info icon
  by the field name (the pattern the app's own Settings modal uses), and
  every field has one now instead of the 9 that did

Verified against a live repeater from the local container: expanding all
8 sections makes 0 requests, a single-field read sends exactly
`?section=advanced&fields=rxdelay`, the value survives a reload from cache
with the stale note, and Apply still round-trips (dutycycle rewritten to
its own value).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 23:09:06 +02:00
MarekWoandClaude Opus 5 01976440fb feat(repeaters): expose the rxdelay setting in Advanced
Settings -> Advanced carried txdelay and direct.txdelay but not rxdelay,
which CommonCLI has had all along for every repeater and room server
(get + set, float 0-20, 0 = off by default). Reaching it needed a raw
CLI command.

It is a distinct mechanism from the tx delays: calcRxDelay staggers the
repeating of flood packets by packet score, so a strong copy repeats
sooner and a marginal one waits and drops the repeat as a duplicate.

Adds the field to _REPEATER_SETTINGS_FIELDS and SETTINGS_SECTIONS (the
two lists must stay mirrored) plus en/pl labels and help. `af` stays out
on purpose: dutycycle replaced it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 20:49:50 +02:00
MarekWoandClaude Opus 5 3c5ebca132 fix(paths): refuse duplicate routing paths for a contact
The path list is a retry order, so the same route twice means retrying a
route that has just failed while a genuinely different one waits. Both
editors (contact info and My Repeaters) POST to the same endpoint, so the
guard sits there: adding or editing a path onto a route the contact
already has is refused with 409 duplicate_path, and the modal stays open.

Paths are now normalized to lowercase on write and existing duplicates are
collapsed on startup (keeping the primary copy), which lets a unique index
back the check. The index is created in the migration rather than
schema.sql, which runs before duplicates are removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 21:33:05 +02:00
MarekWoandClaude Opus 5 3ea543eb7d chore: open 2.14.0 development
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 21:25:28 +02:00
MarekWo b95572a428 Merge branch 'dev' 2026-08-30 21:25:01 +02:00
MarekWoandClaude Opus 5 dba48ca2b8 chore(release): finalize 2.13.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 21:24:57 +02:00
MarekWoandClaude Opus 5 710c5a3336 fix(repeaters): keep the neighbours toolbar on one line
With a real list loaded, the "N neighbours" counter shared the toolbar
row and pushed the kebab button onto a second line — on a 360 px phone
the four controls already fill the row on their own. The counter now sits
on its own line under the toolbar, where it reads as a caption for the
table, and the buttons keep a fixed position whether the list has loaded
or not. Verified one-line at 320/360/390/412 px.

Also trims the name column to 32vw on narrow screens: at 40vw a list of
25 neighbours with minute-and-second ages pushed the SNR column out of
the table's scroll box. The full name is in the detail dialog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 06:49:44 +02:00
MarekWoandClaude Opus 5 9414563a22 docs: document neighbour discovery and removal
Covers the kebab menu and the detail dialog in the user guide, the two
new API entries in architecture.md, and a What's New entry under
Unreleased.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 22:07:06 +02:00
MarekWoandClaude Opus 5 77fc1230ea feat(repeaters): discover neighbours and remove them from the list
The Neighbours pane could only read the repeater's list. The standard
MeshCore app offers two more commands from its overflow menu, and there
was no way to reach either from here:

- `discover.neighbors` — repeater-firmware only, so a node type that does
  not have it answers with an error we surface verbatim rather than
  reporting a success
- `neighbor.remove <prefix>`, with an empty prefix clearing the whole list

Remove-all deliberately gets its own endpoint instead of going through
/cli: the firmware matches `neighbor.remove ` on all 16 bytes including
the trailing space, and the CLI endpoint strips the command, so the
console cannot express it at all. The prefix is validated as hex of even
length — the firmware derives the byte count as len/2 and would silently
match a wider set than asked for.

Both actions are admin-only (the list itself needs only a login) and live
behind a kebab menu next to Refresh, which keeps the header on one line
on a phone once the Refresh label is dropped below `sm`.

Single removal goes through a detail dialog opened by selecting a row or
a map marker, rather than a trash icon per row: that row already
truncates names at 390px, and a delete button at the edge of a scrolling
list is the easiest thing in the panel to mis-tap. The dialog doubles as
somewhere to show the pubkey prefix, SNR, last-heard time and position,
which the list has no room for; guests see it without the button.

Also caps the name column at 40vw and scrolls the table in its own box,
so a long neighbour name no longer drags the whole page sideways.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 22:07:01 +02:00
MarekWoandClaude Opus 5 92ea5dae5d fix(demo): stop locking per-browser preferences in demo mode
The Interface tab carried data-demo-lock as a whole, which greyed out the
sidebar layout breakpoint along with the toast settings. The breakpoint is
localStorage — per browser, invisible to every other visitor — so it belongs
with theme, notification permission and the quick-access bar, all of which
were already allowed. The toast settings are server-side and shared, so the
lock moves onto the uiSettingsForm that owns them.

Audited every other locked region against the localStorage key list: this was
the only per-browser control caught by a lock. Notifications, theme, FAB
placement/size/position and the sidebar breakpoint are now the complete set of
preferences a demo visitor may change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 18:37:34 +02:00
MarekWoandClaude Opus 5 ece8ebb6d2 docs: document advert coordinate validation
whatsnew.md went in with the fix; architecture.md and troubleshooting.md
did not.

- architecture.md: new section on why one impossible coordinate blanks a
  whole map (Leaflet clamps latitude, not longitude), the four places the
  check is applied, and why a corrupted `type` is logged rather than
  normalised. app/geo.py and geo-utils.js added to the file tree
- troubleshooting.md: user-facing entry for the blank map and the
  world-spanning route, naming the two log lines that identify the
  offending adverts, and saying plainly that updating is enough

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 11:46:15 +02:00
MarekWoandClaude Opus 5 8f1334ee00 fix(map): reject impossible advert coordinates before they blank the map
A tester's cached-contacts map came up empty and Path Analyzer drew routes
across the globe. Two of his 2131 cached contacts carried garbage positions
from corrupted adverts - lat 1642 / lon -323 and lat 309 / lon -1768.

Leaflet clamps latitude when projecting but not longitude, so fitBounds()
spanned 1797 degrees, dropped to zoom 0 and centred on lon -870; all 2273
real markers were rendered several world-widths outside the viewport.
Reproduced against his DB: zoom 0, bounds -1431..-309. The device-only view
was fine because both bad rows are cache-only.

Four layers, so no single miss can blank a map again:

- app/geo.py: one definition of a usable coordinate, applied on write in
  Database.upsert_contact() so the cache stops storing garbage, and on API
  output so a cache poisoned by an older build heals itself with no
  migration. Corrupted advert types (61, 126, 152, 210 seen in the wild)
  are logged but kept raw - they already render as UNKNOWN.
- static/js/geo-utils.js: hasValidGps()/withValidGps()/fitMapToPoints()
  replace eight copies of the same check across six files. Path Analyzer
  had a looser variant that also accepted lon == 0, which put nodes off
  west Africa and stretched routes across the map; it now shares the rule.
- Per-marker try/catch and a bounds validity check, so an entry that still
  slips through costs its own pin rather than the whole map.

Verified against the tester's DB in a container: cached map 2273 markers,
lon span 1797 -> 19 degrees, centred on Poland; the route that drew the
world-spanning line (msg 29499, token 72AF) now renders as a real
Krakow-Kielce path with the bad hop left unresolved. Live local instance
regression-checked on all five map pages. tests/test_geo.py: 43 passed;
full suite 290 passed, 2 pre-existing failures unrelated to this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 11:35:53 +02:00
MarekWoandClaude Opus 5 ad17791c3a docs: move the notification entries under Unreleased
The cherry-pick that replayed them over the 2.12.0 release boundary put both
sections' bullets in the closed 2.12.0 section, which had already been tagged
and published. The 2.12.0 section is now byte-identical to v2.12.0 again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:05:45 +02:00
MarekWoandClaude Opus 5 2a78d3fed3 feat(notifications): name the sender, quote the message, land on the right screen
A notification said "New: 1 channel" and nothing more, because
sendBrowserNotification() was driven by badge-counter deltas and never saw a
message. The socket payload has carried the sender, the text and the channel
all along.

Channel messages now notify as "<channel>" / "<sender>: <text>", DMs as
"<sender>" / "<text>", each conversation on its own tag so one cannot bury
another, and repeats on the same tag collapse with a count in the title.
Notifications carry a deep link in options.data.url; a tap resolves it in place
when the page is already up, and the wrapper hands it to the Intent so a cold
start lands there too. Channel links are new (/?channel=<idx>); DM links reuse
the conversation parameter /dm already accepted.

Two faults that made notifications look unreliable while the app was awake:

- A message on the channel last left on screen never notified at all. A channel
  now counts as open only while the document is visible.
- The first checkAndNotify() compared against zeroed baselines, so a page
  loading in the background announced its whole backlog as new ("New: 492
  channels"). The first pass now only takes the measurement.

The count-only notification stays as the fallback for messages the socket never
delivered; the socket path advances its baselines so the same message is not
announced twice.

dm.js's checkDmNotifications() is removed rather than updated: dm.html never
loads app.js, so its areNotificationsEnabled() guard returned on the first line
in every context it ran in, and it would now be a second, vaguer alert for a
message app.js already announces in full.

Verified against the local container with a Playwright harness (14 checks):
content, collapsing, mute and own-message suppression, taps on a running page,
cold starts on both link kinds, and no stray count-only alerts.

Background delivery on Android is a separate, architectural problem and is
untouched here - the wrapper still has no foreground service, so a frozen
process still stops notifications altogether.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:04:44 +02:00
MarekWoandClaude Opus 5 98f2afff7e chore: open 2.13.0 development
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:04:38 +02:00
MarekWo 7bce66f2ad Merge branch 'dev' 2026-08-27 16:04:08 +02:00
MarekWoandClaude Opus 5 0c4b402dd5 chore(release): finalize 2.12.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:03:58 +02:00
MarekWoandClaude Opus 5 2af68c44b3 fix(clock): sync repeater clocks from the server, not the drifting node
"Sync clock" under My Repeaters sent the firmware's `clock sync`, which
sets the repeater RTC from the CLI packet's sender_timestamp. The
companion overwrites that field with its own RTC before transmitting
("Use node's RTC instead of app timestamp", companion_radio/MyMesh.cpp),
so the button copied the node's drift onto the repeater instead of the
server's time — observed as a repeater set 56 minutes slow. Send
`time <epoch>` instead: the value travels in the command text, taken
straight from the host clock and untouched in between.

The node's own clock is now checked on connect and hourly, and set from
the host whenever it has drifted more than 30s. It is only ever set by
hand today, so it free-runs: the local test device was 16 hours behind.
CMD_SET_DEVICE_TIME is refused when it would move a clock backwards, so
a fast-running device is logged loudly rather than retried in silence.

Also name the timezone wherever a clock is printed. The Status row
renders in the browser's zone, the console in the container's, and
repeaters answer their own `clock` in UTC — three unlabelled renderings
of the same instant, which is what made this look like chaos rather
than drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 21:48:44 +02:00
MarekWoandClaude Opus 5 37d32f5529 fix(ui): hide the Quick Access bar by default, make it opt-in
On phone-sized screens the floating column overlapped the message
composer and its bottom button landed on top of send, so tapping send
hit the FAB instead. Enough testers reported it to make clear the
default was wrong rather than their aim.

isFabHidden() now treats a missing key as hidden, so only an explicit
'false' shows the bar. The storage key and its meaning are unchanged,
which keeps every browser that already made a choice on that choice:
the new default reaches only browsers that never touched the setting.

The checkbox is inverted to match — "Show Quick Access buttons",
unticked by default — because a negatively phrased toggle that ships
ticked reads as a double negative. Renamed settHideFab -> settShowFab
and the two translation keys with it, en and pl both updated.

With the bar off, applyItemPlacements() already routes every item to
the main menu, verified in a real browser: filter, search, DMs,
contacts and settings are all reachable under the hamburger, the send
button is unobstructed, the choice survives a reload, and a browser
holding 'false' keeps the bar.

Not covered: the direct-message screen has its own FAB container that
does not read this setting. Hiding it there would strand the DM filter
and settings, which have no menu equivalent, so it stays visible for
now and is called out in whatsnew.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 10:13:19 +02:00
MarekWoandClaude Opus 5 cbbbcbc8b2 fix(chat): escape message text spliced into action-button handlers
A message containing an apostrophe followed by angle brackets — e.g. a bot
answering "Type 'channel <name>' for a description" — tore the action row
below it apart: the apostrophe closed the single-quoted onclick attribute,
the '<' ended the <button> tag, and the remainder ("...")' title="Quote">)
landed on the page as visible text, with reply and quote dead.

JSON.stringify() produced a valid JS literal but was spliced into markup
raw. jsArg() wraps it in escapeHtmlAttribute(), so quotes and brackets
survive as entities and the JS parser sees the literal only after the HTML
parser decodes the attribute. Applied to the quote/edit buttons in channel
chat and DM, and to the sender-name handlers (reply, map, block) that had
the same defect for a name containing an apostrophe.

Verified in a browser against the local container: all ten test messages
now render six clean buttons with no stray text, and quoting '<>' fills the
composer correctly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:57:23 +02:00
MarekWoandClaude Opus 5 93f171a1c1 fix(demo): make the demo notice readable on the console page
console.html and logs.html are always-dark terminal pages that
deliberately do not load theme.css, so the var(--text-secondary) the
notice asked for resolved to nothing there and the text fell back to
Bootstrap's near-black body colour — roughly 1:1 against the navy
background, i.e. invisible.

Fixed twice over. Every colour in the demo CSS now carries a fallback,
so a page without theme.css degrades to a readable light notice instead
of to nothing — that closes the whole class of bug rather than this one
instance. And console.html restyles the notice in its own palette,
alongside the other hardcoded colours it already owns.

Measured contrast: console 12.22:1, light theme 7.76:1, dark theme
5.71:1 (WCAG AA for small text is 4.5:1).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:53:15 +02:00
MarekWoandClaude Opus 5 be0929a3ae feat(demo): read-only demo mode for a publicly shared instance
mc-webui has no login, which is fine on a LAN and not fine once the
address is handed to testers: they assume the node is theirs and rename
it, and the console is right there for anyone curious enough to type
into it.

MC_DEMO=true refuses everything that reconfigures the radio, destroys
data, reaches other people's repeaters, rebuilds the containers,
publishes to an external broker, or hands out a secret. Reading and
sending messages keep working, so the instance is still worth visiting.

The guard is default-deny: an api_bp.before_request hook refuses every
non-GET unless it is on a short allowlist, so an endpoint added later is
locked without touching this module. That mattered — six mutating
endpoints already had no frontend caller and would never have made a
hand-written blacklist. A small read denylist covers the GETs that leak
(repeater passwords, backup download, capture download, /api/logs),
which the write guard never sees.

Two paths bypass the HTTP layer and are closed separately: the console
is a Socket.IO event, not a route, so it is checked against a read-only
command set; the log viewer is refused at the connect handshake.

Access is kept two ways. MC_DEMO_TRUSTED_NETS unlocks by CIDR, and
MC_DEMO_UNLOCK_CODE unlocks by a code typed once in Settings and held in
an HMAC cookie for 30 days — stateless, and deliberately not Flask's
session, whose SECRET_KEY is a hardcoded public constant.

The trap the network list walks into: published through a tunnel, every
visitor arrives from the tunnel's address, and if the tunnel runs on the
LAN then trusting the LAN unlocks the instance for the whole internet.
An address arriving with a proxy header is therefore not matched unless
MC_TRUST_PROXY is on, and startup logs report the resulting shape.

Frontend is advisory only: demo-lock.js loads from _head_i18n.html so it
reaches all eight entry points including the six iframes, greys out
[data-demo-lock] rather than hiding it (the instance exists to show the
app off), re-applies via MutationObserver for JS-rendered buttons, and
turns any 403 demo_locked into a toast.

Verified on the local container: the rename, reboot, updater, vacuum,
cleanup and the leaky GETs all 403; sending, read markers and ordinary
reads pass; `set name` is refused over the console socket while `infos`
still answers; unlock/lock round-trips. 86 new unit tests, en/pl at 100%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:20:53 +02:00
MarekWoandClaude Opus 5 b5e5c69faf chore: open 2.12.0 development
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 08:26:33 +02:00
MarekWo 857a9e421d Merge branch 'dev' 2026-08-20 08:26:00 +02:00
MarekWoandClaude Opus 5 9298a1720e chore(release): finalize 2.11.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 08:25:49 +02:00
MarekWoandClaude Opus 5 61a268f2cb docs: publish the 2.0 build of the Android app
The wrapper sources moved to targetSdk 36 and versionName 2.0, but the
`.apk` shipped from this repository was still the 1.3 build, so the
download table described a file that no longer matched the code beside it.

Replaces the published binary and the figures that identify it: 5.2 MB,
version 2.0, and a new SHA-256. Verified before committing - apksigner
reports the APK as signed by the same certificate as every release before
it (SHA-256 425857b3...9bd230), so it installs as an update rather than
forcing a reinstall, and aapt2 confirms versionCode 5 against targetSdk 36.

Also adds the release notes for both Android changes in this cycle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 08:25:31 +02:00
MarekWoandClaude Opus 5 a36fe9ebb7 fix(android): follow the mc-webui theme without a restart
The system bar strips took their colour from the page only when a page
finished loading or the app came back to the foreground. mc-webui switches
its theme by flipping data-theme on the document element - no reload, no
trip through onResume - so switching to the dark theme left a white strip
under the status bar until the app was restarted.

A MutationObserver on that attribute closes the gap. It lives entirely on
this side, so mc-webui still needs no changes, and it reports twice: once
immediately and once after 300 ms, because the theme change animates and
an immediate read catches a colour mid-transition.

The callback arrives through a second, separate JavaScript bridge rather
than an extra method on the notification one. That bridge is deliberately
minimal, and a one-way "here is a colour" channel has no business sharing
a surface with notification permissions.

Verified on a real device: the strips now follow the theme both ways
without leaving the app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 08:15:40 +02:00
MarekWoandClaude Opus 5 adb9efc17a docs: add a privacy policy for the Android app
Google Play requires every listing to link a publicly reachable privacy
policy, whether or not the app collects anything. This one is served from
the repository itself, so its history is public alongside the code it
describes.

English and Polish on one page, self-contained and theme-aware like
contact-management.html. The content is drawn from what the app actually
does: nothing reaches the developer, and what stays on the phone is the
server address, the per-host HTTP credentials, the notification-prompt
flag and the WebView's own cache - each listed with the reason it exists.
Permissions get the same treatment, one row each.

docs/.nojekyll is not optional here. GitHub Pages runs Jekyll by default
and docs/architecture.md contains brace syntax Liquid tries to interpret,
which would fail the build and take the policy URL down with it. Serving
the folder as static files avoids the whole class of problem.

Rendering checked in Chrome at 390px and 1000px, light and dark: no
horizontal overflow, tables fit without sideways scrolling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 16:54:40 +02:00
MarekWoandClaude Opus 5 7499930cad chore(android): target API 36 and handle enforced edge-to-edge
Google Play rejects anything below targetSdk 36 submitted after
2026-08-31, so the wrapper had to move off 34 before it can be published
at all.

The toolchain came along for the ride: AGP 8.2.0 -> 8.13.2, Gradle
8.7 -> 8.14.3, Kotlin 1.9.22 -> 2.2.21, and the AndroidX libraries to the
newest releases that still build against Android 16. The very newest ones
(core-ktx 1.19) want AGP 9.1 and compileSdk 37, which is a DSL migration
of its own and buys nothing here.

Targeting 36 means the system draws behind the status and navigation bars
with no way to opt out, so the padding that used to arrive for free is now
applied by hand - system bars, display cutout and the keyboard, all on the
root. Padding the root leaves strips of its background behind the bars,
and a white strip above a dark mc-webui theme reads as a bug, so the page
is asked for its own background colour after every load and the strips are
painted to match, with the bar icons flipped to stay readable.

Verified from the CLI: assembleDebug, assembleRelease and bundleRelease
all pass, and aapt2 confirms targetSdk 36 with the permission set
unchanged. Still needs a look on a real Android 15/16 device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 16:42:17 +02:00
MarekWoandClaude Opus 5 ab4e5f9a6e chore: open 2.11.0 development
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:56:53 +02:00
MarekWoandClaude Opus 5 ddf1e9ec2d Merge branch 'dev' for 2.10.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:56:24 +02:00
MarekWoandClaude Opus 5 dda56ab75b chore(release): finalize 2.10.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:56:13 +02:00
MarekWoandClaude Opus 5 135e0829b9 feat(chat): "New messages" divider and scroll to first unread
Opening a channel always landed on the newest message, so catching up on a
busy channel meant scrolling up and hunting for the last familiar message.
Both message lists now draw a divider at the read/unread boundary and open
on it instead of at the bottom.

The boundary is frozen on entry (unreadAnchorTs / dmUnreadAnchorTs) because
displayMessages marks the view read on every render and would otherwise
erase it before it could be drawn. It is recomputed only on a channel or
conversation switch, so resyncs, the geo-cache re-render and the 60s DM
refresh keep the divider in place rather than making it vanish a second
after arriving.

No divider when there is nothing to catch up on, on a first visit
(last_seen 0) or in the archive view. When every loaded message is unread
the divider renders first but the view stays at the bottom — the list
already starts there and scrolling would imply history above it.

Also fixes two scroll-position bugs this exposed:
- DM displayMessages scrolled to the bottom unconditionally, so the
  periodic refresh yanked anyone reading history. It now honours a
  dmIsUserScrolling flag, matching channel chat.
- Sending resets the flag in both views; without it a user parked at the
  divider would send into a part of the list they cannot see.

Verified in Chrome against the local container across light/dark and
desktop/mobile: boundary lands between the anchor and the next message,
divider spans the full width in both flex lists, filter hides it, and an
incoming message leaves the view parked while an own send drops to bottom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 14:04:12 +02:00
MarekWoandClaude Opus 5 3e3364bfc2 docs: cover in-chat share tokens and cards
user-guide gains two sections: how a received card behaves and what each
button state means, and how the "+" button shares something.

architecture gains "Share Tokens & Cards" — the three wire formats with
the byte arithmetic that explains why contacts use a compact token rather
than the URI, why parsing has to run before HTML escaping, the per-page
hooks the DM iframe needs, and two traps worth not rediscovering: never
compose an i18n key (a composed key hides a missing translation from
i18n_check.py — that is how share.card.private_channel shipped missing),
and cards belong in .message-content, never .message-meta.

whatsnew describes both halves for users, in the release voice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 11:16:03 +02:00
MarekWoandClaude Opus 5 e4a98a427e feat(chat): add the share composer — "+" button, pickers and live preview
Completes the sharing feature: you can now send what the cards render.

A "+" button next to the emoji button opens a three-item menu — contact,
channel, location. Five actions in the official app collapse into three
because "me" and "my position" are not separate destinations, just the
first, pinned entry of the picker they belong to. Same number of taps,
one less menu level, and every entry behaves identically.

The payload is inserted into the input as plain text, byte-identical to
what the official app sends. A protected "chip" was considered and
dropped: it is unknown whether the official app renders a card when the
token is not the whole message body, and that risk is not worth the
cosmetics. The reassurance went into a live preview above the composer
instead, rendered through the very same processMessageContent() the
recipient will use — so it cannot drift. Edit the token by hand and the
preview vanishes, which is immediate feedback that the message will no
longer render as a card.

Details worth knowing:

- The contact picker lists device memory and cache together, since the
  cache is where adverts land and those are the contacts worth passing
  on. It renders at most 150 rows: this mesh has over 1500 cached
  contacts, and the full list costs thousands of DOM nodes for something
  nobody scrolls to the end of. Search runs over the whole set.
- The channel picker carries the region scope along, because a recipient
  transmitting under the firmware default would not match the sender.
- The location picker holds both cases in one dialog: our own position as
  a button, plus a Leaflet map for any other point.
- Byte budgets are enforced up front (135 channel / 150 DM). A token that
  cannot fit is refused rather than truncated — shortening a public key
  or a channel secret destroys it.
- The DM composer starts disabled until a conversation is chosen, so the
  button mirrors the input's disabled state via a MutationObserver.

Also fixes _selfInfo being populated only inside the map modal's shown
handler, which left it null on a fresh page — the location cards need our
own position at render time for distance and bearing.

Verified in a real browser against the live device: 32 assertions on the
channel composer, 13 on the DM panel (including its local map modal),
both suites green with no console errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 11:12:58 +02:00
MarekWoandClaude Opus 5 2acf6f9f3a feat(chat): render shared contacts, channels and positions as cards
MeshCore clients share contacts, channels and positions as plain text in
ordinary chat messages. Until now those arrived as unreadable raw tokens.
They now render as interactive cards in both the channel chat and DMs.

Three wire formats, none of them ours to choose — they are what the
official app emits, so share-tokens.js stays byte-compatible with it:

  contact   <64-hex-pubkey:type:name>   (compact, not a URI)
  channel   meshcore://channel/add?name=&secret=&region_scope=
  location  bare "lat,lon"

The contact URI form is parsed too (QR codes carry it) but never emitted:
with a name like "MarWoj 💡📱 mobile" it costs 153 bytes and cannot fit a
135-byte channel packet, where the compact token needs 91.

Parsing runs BEFORE HTML escaping, leaving private-use-area placeholders
that the later passes cannot match. Tokens do not survive the existing
pipeline otherwise: escaping breaks a URI's '&', processChannelLinks eats
a '#channel' name, processMentions an '@' in a contact name, and the
token's closing '>' collides with quote syntax.

Cards resolve their action before rendering, so the button states what
will actually happen rather than surprising the user afterwards:
  contact  Add / Update name to X / Already in contacts / Push to device
  channel  Add / Already joined / Name used by a different key
The public key is the identity — a known key under a new name is a
rename, never a second contact.

Shared contacts land in the local cache rather than device memory:
they arrive unsolicited and contact slots are limited, so promotion stays
deliberate. The card offers it inline once cached, so it costs one click
rather than a trip to the Contacts panel. New: manual-add accepts
target=cache, which needs no device connection.

Location cards add distance and bearing from our own advertised position,
plus map/copy/external-map actions. A shared channel's region scope is
carried over on join, since transmitting under the firmware default would
not match the sender.

Coordinate detection is deliberately narrow — both numbers need 3+
decimals, ranges are checked, and matches inside URLs are skipped — so
prose like "1,5 osoby" or "wersja 2,10" and Google Maps links are left
alone.

Verified against real payloads received from another device on #mcwebui,
covering all three formats plus a contact URI and emoji names: 65 parser
assertions, 57 render assertions, i18n check clean at pl 100%, and both
themes screenshotted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 10:57:26 +02:00
MarekWoandClaude Opus 5 e03aaca489 feat(repeaters): capability-aware settings, v1.17 fields and power-off
Firmware v1.17 changed no protocol constants and left FIRMWARE_VER_CODE at
13, so the companion protocol cannot tell a v1.16 node from a v1.17 one.
Capability is instead read from the CLI reply text, which self-describes and
works for any version and board without an extra round-trip:

  `??: <field>` / `unknown config: <field>`  → firmware has no such setting
  `Error: unsupported`                       → hardware cannot do it
  `> <value>`                                → supported

Both of the first two now yield an 'unsupported' status rather than a
failure, rendered as a muted badge with the control locked and excluded from
Apply. Without this, every field added below would read as a red error on
each repeater that predates it.

Applying to an unsupported field deliberately asserts nothing about the
stored value: v1.17 persists radio.rxgain before it learns the radio cannot
apply it, so the old value may or may not still hold. The baseline is
dropped instead of being reasserted, and only Refresh reveals the truth.

Also guards a pre-existing trap: up to v1.16 the `radio.rxgain` getter was
compiled out on non-SX126x radios, and `get radio.rxgain` fell through to
the `radio` branch (its memcmp has no trailing space), answering with the
radio parameters. That parsed as a valid reply and silently rendered the
switch as off. An on/off field that answers neither on nor off is now
treated as absent.

New fields: cad (v1.17+), flood.max.unscoped and flood.max.advert (both
present since v1.16, previously just undocumented upstream).

New action: poweroff (v1.17+). Unlike reboot it cannot be undone over the
mesh, so it takes a typed repeater name rather than a confirm(), checked
server-side as well as in the UI.

Telemetry: the MCU temperature v1.17 appends to channel 1 is labelled as
silicon rather than as an environmental reading. Absent stays absent —
boards without a usable sensor emit nothing, and a zero would be a lie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 18:31:32 +02:00
MarekWoandClaude Opus 5 8f3e04ae80 chore: open 2.10.0 development
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:04:14 +02:00
MarekWo 1a444a85cd Merge branch 'dev' 2026-08-14 12:19:20 +02:00
MarekWoandClaude Opus 5 9cadd067c5 chore(release): finalize 2.9.1
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:19:12 +02:00
MarekWoandClaude Opus 5 8ce57f2868 fix(mobile): stop iOS zooming the page when a field is focused
iOS WebKit zooms the whole page when a focused form control computes to
under 16px. `#messageInput` was 0.9rem (14.4px) on narrow screens, so
every tap on the message box tripped it - reported on iPhone in both
Safari and Chrome, on a phone with no configuration of its own.

The zoom also never undid itself: the shell is `height: 100dvh;
overflow: hidden`, so WebKit has nothing to scroll back and leaves the
page scaled until the user pinches out.

Raise every focusable control to 16px on (pointer: coarse), which also
covers landscape phones wider than the 768px breakpoint. Desktop keeps
Bootstrap's smaller sizing. The guard uses !important on purpose: it has
to outrank page-local <style> blocks that load after style.css and reach
specificities like `.filter-sort-toolbar .form-select`.

Fields that only open a picker (checkbox, radio, range, color, file,
buttons) are excluded - they never trigger the zoom.

The contacts toolbar was the one layout that could not take the bump:
three 16px selects overflow one unwrapped flex row, clipping the sort
control, so it now wraps.

Verified locally at 390x844 and 932x430 with touch emulation: no control
under 16px on any route, no horizontal overflow, desktop unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 11:34:38 +02:00
MarekWoandClaude Opus 5 e848bc16a4 chore: open 2.10.0 development
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 10:53:33 +02:00
MarekWo b8576cc26a Merge branch 'dev' 2026-08-14 10:52:36 +02:00
MarekWoandClaude Opus 5 4b89a02364 chore(release): finalize 2.9.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 10:52:31 +02:00
MarekWoandClaude Opus 5 56e1100e61 fix(channels): make the mobile channel picker a button, not a text field
Tapping the navbar channel selector focused a text input, so Android
raised the on-screen keyboard on every channel switch — half the screen
covered for a list that is four or five items long. The type-to-filter it
enabled solved a problem nobody has: the firmware caps how many channels
a device holds, so there are never enough to search.

#channelSelectorInput becomes #channelSelectorButton, a <button> carrying
the same .form-select chrome, and click toggles the dropdown. The list
itself is unchanged (previews, unread badges, favourites, muted styling),
so renderChannelDropdownItems() just loses its query parameter and the
channels.dropdown.no_matches key goes with it. Keyboard access is kept:
Enter/Space opens, arrows move, Enter selects, Escape closes — the
keydown handler now bails out while the dropdown is closed so Enter
performs a plain button activation instead of picking the first row.

Verified locally at 360px: focus lands on the button (never an editable
element), open/toggle/select/outside-click/Escape all behave, 0 console
errors, and the control renders identically in light and dark themes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 16:50:28 +02:00
MarekWoandClaude Opus 5 4a5e70abca feat(paths): add a "Set Direct" button next to "Restore FLOOD"
The path dialog could send a node back to FLOOD or push a configured
path, but Direct — the firmware's third mode, zero hops — had no button;
getting there meant clearing the path from the Console.

A new POST /api/contacts/<key>/paths/set_direct sends an empty path hex,
which makes the firmware store out_path_len = 0, the state
_format_path_display() already renders as "Direct". Configured paths are
untouched, same as Restore FLOOD.

The button is wired in both places paths are managed: the DM contact info
dialog and the My Repeaters path modal. Both button rows get flex-wrap so
the third button wraps instead of overflowing at 360px.

Verified against the local test device: the endpoint moved a repeater
from 5E34→EE6C to Direct and back, and both dialogs render correctly in
en/pl at 1280px and 360px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 08:57:42 +02:00
MarekWoandClaude Opus 5 0c7ad6c73b feat(device): reboot the device from the Device Info panel
Restarting the node meant opening the Console and typing `reboot`, or
reaching the hardware. Device > Info now carries the same action as a
button, with a confirm that says what it costs: the device leaves the
mesh for a few seconds and anything addressed to you in that window can
be lost.

POST /api/device/reboot wraps the DeviceManager.reboot_device() the
console command already used. The firmware acknowledges nothing and the
companion link goes down with it, so a 200 means "command written", not
"device is back" - the reconnect is _on_disconnected()'s job, and it
takes about five seconds over TCP.

The button is rendered by loadDeviceInfo() rather than sitting in
base.html, so it only exists on the branch where the device answered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 07:23:11 +02:00
MarekWo e6497e31dd chore: open 2.9.0 development 2026-08-13 07:08:04 +02:00
MarekWo 8fbd1e7b3d Merge branch 'dev' 2026-08-13 07:07:46 +02:00
MarekWoandClaude Opus 5 38dddf0ace chore(release): finalize 2.8.0
Drop the -dev suffix and date the pending whatsnew section. Also record the
two repeater path-wrapping fixes (31729fd, 8224128), which landed after the
docs pass and had no user-facing entry yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 07:07:38 +02:00
MarekWoandClaude Opus 5 8224128da7 fix(repeaters): let a long path wrap in the list instead of being cut off
Same defect as the management header, different symptom: the row put the path
in a .text-truncate line, so on a phone a multi-hop path ended in an ellipsis
and the remaining hops were unreadable. The path line now wraps (per hop, via
the same zero-width space) and keeps its own row width; the logging-in and
not-on-device variants are short and stay truncated as before.

The add-repeater picker and the current-path line in the path modal show the
same string and got the same treatment.

Verified at 360 px against the running container with stubbed API data: list
row, picker item and modal each wrap to two lines with zero overflow, and the
page reports no console errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 15:48:14 +02:00
MarekWoandClaude Opus 5 31729fd3f3 fix(repeater): keep the header path and location inside narrow screens
A multi-hop path is joined with '->' arrows and no spaces, which offers the
browser no break opportunity, so on a phone it ran off the right edge instead
of wrapping. Insert a zero-width space after each arrow so it wraps per hop,
with overflow-wrap as a fallback for a single unbreakable token.

The pin icon and the coordinates were separated by an ordinary space, so a
narrow line could break between them. Keep them together as one inline-block.

Verified at 320 and 360 px: the long-path card overflowed its box by 71 px and
31 px before, 0 after; the pin no longer parts from its coordinates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 15:40:32 +02:00
MarekWoandClaude Opus 5 c96f1fd1d0 feat(diagnostics): record a support capture the maintainer can read offline
Some faults cannot be diagnosed from a screenshot on a server we have no
access to. The one that prompted this: a sent channel message gets no
repeater badge, and nothing in the database distinguishes "nobody in
earshot repeated it" from "the repeat came back over the air but the
firmware dropped the RX-log frame before the app ever saw it".

Settings > Diagnostics records a bounded window into a zip: every RX-log
frame, the echo correlator's decision (with the pending-send list that
explains a non-match), what each send expected to hear back, the
application log at DEBUG, and the device's own packets.recv counter at
both ends of the window. That last comparison is the whole point - it
turns the firmware's silent 4-frame queue overflow into a measurement
instead of a hypothesis.

scripts/diag_report.py reads a capture offline and prints the verdict.

Captures can be downloaded, or uploaded to a Zipline instance once the
user pastes a token handed to them out-of-band. The URL ships as a
default; the token never does, because this repository is public.

- Hot path follows observer.py's contract: one deque append, no locks,
  no I/O. The log handler uses a no-op lock object rather than
  lock = None, which raises under Python 3.13's `with self.lock` in
  Handler.handle() - latent on the 3.11 container, fatal on 3.13
- Caps: 5/15/30/60 min, 25 MB, 10 stored captures. All enforced by the
  writer thread, so a user stop and an auto-stop finalise identically
- The capture contains the text of messages received while it runs; the
  UI states this and confirms before any upload

Also drops a redundant function-local `from flask import current_app` in
get_status(): it shadowed the module-level import for the whole function
and broke /api/status as soon as anything referenced current_app above
it. Found by the capture tool, in its own change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 15:10:12 +02:00
MarekWoandClaude Opus 5 92ff649984 chore: open 2.8.0 development
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:23:31 +02:00
MarekWo 56a8acbd52 Merge branch 'dev' 2026-08-07 16:22:57 +02:00
MarekWoandClaude Opus 5 70e5238cfb chore(release): finalize 2.7.0
Also documents the two behaviours the release adds to Resend that only
whatsnew had covered: the stale-message confirmation, and that a resend
now repairs a message left with no delivery badge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:22:47 +02:00
MarekWoandClaude Opus 5 6aa71ba2e5 feat(device): complete the Stats tab and group it by topic
The tab read fields the firmware never sends. get_stats_core/radio/packets
return uptime_secs, queue_len, tx_air_secs, rx_air_secs and recv; the tab
looked for uptime, queue_length, tx_air_time, rx_air_time and received, and
every one of those rows silently dropped out - leaving battery, errors and
Packets TX next to the database counters. The `stats` console command reads
the same payload with the right names, which is why it showed more.

Fix the names and add what was never shown at all: last RSSI, last SNR, noise
floor, the flood/direct split on both packet counters, RX errors, and channel
utilization. The rows now sit in four titled groups - System Information,
Radio Statistics, Packet Statistics, Database - reusing rptmgmt.status.* for
the labels the repeater status view already had, so the local device and a
remote repeater read the same way. Airtime becomes a duration ("8h 31m")
rather than minutes, for the same reason.

Only device.stats.database is a new key. The user guide's description of this
tab listed free memory and a forwarded counter, neither of which exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:17:27 +02:00
MarekWoandClaude Opus 5 a2053a268a fix(pathanalyzer): show the hop token as code, not escaped markup
The map popups for a hop and for an ambiguous candidate passed the
<code> wrapper as a tHtml() param. tHtml() escapes params by design and
trusts only the catalog string, so the popup rendered the literal text
"<code>47</code>". Move the wrapper into the en/pl catalog values and
pass the bare token, which keeps the token itself escaped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 15:59:06 +02:00
MarekWoandClaude Opus 5 b87512d367 feat(chat): confirm before resending a stale message
Raw resend relies on Mesh::hasSeen at the repeaters to suppress the copy,
but that table only holds recent packet hashes. Resending a week-old
message therefore propagates as if new — observed 2026-08-07, when test
resends of a 30-Jul and a 31-Jul message reappeared on every node's #test
dated today, and merged both flights of echoes onto one badge.

Own message bubbles now carry data-timestamp, and resendChannelMessageRaw
warns (with the message's age) past one hour. Missing timestamp means an
optimistic bubble, i.e. fresh, so it never asks.

Verified in Chrome with the POST intercepted: 8 days old warns and
dismissing sends nothing, 59 min sends straight through, 61 min warns in
Polish too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 15:28:59 +02:00
MarekWoandClaude Opus 5 c1703abcd9 fix(echo): track every recent send, not one at a time
A sent channel message learns its pkt_payload — and therefore its route
badge, repeater list and Analyzer link — only by hearing its own echo come
back. Correlation kept a single _pending_echo slot, so any second send or
resend overwrote it and the first message's echo was filed as foreign
traffic. Proven on prod: msg #71179 lost its badge because a resend of
#71177 fired 33 ms before its echo arrived. 16 of 471 own messages over 30
days had no payload; 3 of those were correlation failures (this bug, plus
echoes arriving 62 s and 200 s after the send, past the 60 s expiry).

- _pending_echoes: one entry per message, capped at 32, so concurrent sends
  no longer evict each other
- exact-payload matching stays valid for 5 min (repeats do arrive minutes
  late); the loose channel-hash fallback keeps its 60 s window because it
  can grab a foreign message on the same channel
- resend re-arms from _payload_from_raw_packet() when the message has no
  pkt_payload yet — previously it re-armed only when a payload already
  existed, which is precisely the case that never needs repairing

Verified live against the test device: reproducing the exact prod sequence
(send, then resend an older message 0.4 s later) now correlates both, and
resending a badge-less message recovers its badge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 15:13:36 +02:00
MarekWoandClaude Opus 5 13de1dff3b docs: show the sign-in screens, from proxy admin to phone
Three screenshots the automation could not take: NPM's own login page,
the browser prompt in front of mc-webui, and the wrapper's Sign in
dialog on a real phone - the last one being the whole point of the
Android work, and the one thing no amount of prose conveys.

The two that carried a real hostname were retouched to mesh.example.com,
matching the rest of the guide and keeping a private address out of the
repository history. The phone shot is cropped to the dialog so it stays
legible at the width the page renders it. It lives in android-app.md
next to the feature it illustrates rather than in the HTTPS guide, which
already links there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187QpThw1Y8n7aYDjHtMHax
2026-08-03 09:41:25 +02:00
MarekWoandClaude Opus 5 95e8d760f5 docs(https): add screenshots of the NPM forms
The guide described every field in prose and left the reader to find it
in an unfamiliar admin UI. Seven screenshots now cover the path that
matters: the proxy host details, both certificate routes, and all four
steps of putting a login in front of the instance.

Captured from a real NPM 2.15.1, filled in and cancelled without saving,
so nothing in the running configuration was touched. Everything shown is
example data - mesh.example.com, meshuser, and NPM's own placeholder API
token - and the password field is deliberately left empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187QpThw1Y8n7aYDjHtMHax
2026-08-03 09:20:58 +02:00
MarekWoandClaude Opus 5 75080de12e chore: open 2.7.0 development
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_0187QpThw1Y8n7aYDjHtMHax
2026-08-03 09:10:46 +02:00
MarekWo dc076eac93 Merge branch 'dev' 2026-08-03 09:10:04 +02:00
MarekWoandClaude Opus 5 307eafade3 chore(release): finalize 2.6.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_0187QpThw1Y8n7aYDjHtMHax
2026-08-03 09:09:53 +02:00
MarekWoandClaude Opus 5 f7f0a124f8 docs(https): explain how to require a login, not just that you can
The guide has listed Access Lists under "Why bother" since it was
written, but Step 5 covered only Force SSL, MC_TRUST_PROXY and
MC_BIND_ADDRESS - all of which close the LAN route and none of which
help an instance published to the internet. So the one step that matters
most there was the one the reader had to invent.

Adds the walkthrough, and draws the line between the two ideas the old
text ran together: a login keeps strangers out, closing port 5000 stops
the local network reaching the app around the proxy - and around the
login. Notes that MC_BIND_ADDRESS binds the port to loopback rather than
removing it, and that leaving it open is the right call when testers use
the LAN address, as they do on this project's test server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187QpThw1Y8n7aYDjHtMHax
2026-08-03 08:57:06 +02:00
MarekWoandClaude Opus 5 daed07a335 feat(android): publish wrapper 1.3 with proxy sign-in
Verified live against the test server with an Nginx Proxy Manager access
list: the wrapper prompts, stores the credentials, and reconnects without
asking again. The proxy log shows 24 websocket upgrades (HTTP 101) from
the WebView user agent, so the credentials reach socket.io and the XHRs
too - which was the open question, since onReceivedHttpAuthRequest fires
for the main frame only.

Also documents what that log made obvious: requests refused in the
background raise no prompt, so switching authentication on under a
running app leaves it stuck until restarted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187QpThw1Y8n7aYDjHtMHax
2026-08-03 08:44:43 +02:00
MarekWoandClaude Opus 5 51f6674d6c feat(android): sign in to servers that ask for a password
A reverse proxy can be told to require HTTP Basic authentication - an
Access List in Nginx Proxy Manager, say - which is what makes publishing
an instance to the internet reasonable. The wrapper had no answer for
that: WebViewClient cancels the request by default, so the app showed
nginx's bare 401 page and stopped there.

Handle onReceivedHttpAuthRequest instead, asking for the username and
password once and keeping them per host, so later launches go straight
in. WebView only calls it after a 401 carrying WWW-Authenticate, so an
instance that asks for no password never reaches any of this and behaves
exactly as before.

Credentials are stored before the server has accepted them; a wrong one
simply comes back on the next 401 with the username filled in, since
useHttpAuthUsernamePassword() tells a first ask from a retry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187QpThw1Y8n7aYDjHtMHax
2026-08-03 08:17:32 +02:00
MarekWoandClaude Opus 5 e9029f02a4 docs: drop the reference to the removed v1/v2 archive branches
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 07:42:54 +02:00
MarekWoandClaude Opus 5 63f3784df5 chore: open 2.6.0 development
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 07:42:15 +02:00
MarekWo 4cbb2208f6 Merge branch 'dev' 2026-08-03 07:41:50 +02:00
MarekWoandClaude Opus 5 a958a74920 chore(release): finalize 2.5.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 07:41:44 +02:00
MarekWoandClaude Opus 5 ed23c9be90 docs(https): match the certificate steps to the Nginx Proxy Manager 2.15 UI
Walking through a real setup showed the guide described an older UI. 2.15 does
not offer "Request a new SSL Certificate" with a "Use a DNS Challenge" checkbox
on the proxy host; certificates are created on the Certificates page, which
splits them into "Let's Encrypt via HTTP", "Let's Encrypt via DNS" and "Custom
Certificate", and the proxy host's SSL tab only selects an existing one.

Rewrites all three options against what the UI actually shows, and adds the
parts that turned out to need explaining while doing it for real: how to scope
the DNS provider API token (single zone, and no expiry -- a TTL breaks an
automatic renewal months later, silently), the shape of the Credentials File
Content box, what the "Not Used" status means, and leaving HSTS off at first.

Verified end to end against a live instance: Let's Encrypt via Cloudflare
DNS-01, proxy host to mc-webui:5000, resulting page served over HTTPS with
socket.io on a real websocket transport and http:// redirecting to https://.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 21:57:34 +02:00
MarekWoandClaude Opus 5 a4a9c165d6 fix(i18n): shorten the "no region" badge so the status bar fits on a phone
The bottom status bar of the group chat window carries the connection state,
the region badge and the "Updated:" time. With no region set, the badge read
"Bez regionu" in Polish against "No region" in English -- long enough to push
the row over the width of a narrow phone screen and wrap its contents.

Both languages now use "Region --", which fits and reads the same either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 18:03:49 +02:00
MarekWoandClaude Opus 5 05ec5fa13e feat(https): optional HTTPS front end via Nginx Proxy Manager
Adds an opt-in HTTPS layer without touching anything for existing installs.

The proxy is a service in docker-compose.yml behind the "https" Compose
profile, activated with COMPOSE_PROFILES=https in .env. That keeps the whole
setup inside files git owns while the switch lives in a file it ignores --
important because scripts/update.sh runs `git pull`, so any user edit to a
tracked compose file would break the next update with a merge conflict.
mcupdate needs no change: compose reads COMPOSE_PROFILES from .env itself.

Both services share the project's default network, so the proxy forwards to
http://mc-webui:5000 internally; MC_BIND_ADDRESS lets the plain-HTTP port be
restricted to loopback once HTTPS works, and MC_TRUST_PROXY turns on ProxyFix
so the app sees the real client address and scheme.

Also fixes copying over plain HTTP. navigator.clipboard only exists in a
secure context, and 9 call sites across 5 entry points used it with no
fallback, so copy buttons silently did nothing on a LAN address. They now
share clipboard-utils.js, loaded via _head_i18n.html (the one include every
entry point already has), which falls back to execCommand.

Verified locally against the real stack: self-signed cert uploaded to NPM,
proxy host created, app served over HTTPS with socket.io upgrading to a real
wss:// WebSocket (transport "websocket"), and the clipboard fallback checked
end-to-end by pasting back what it copied over http://.

Two findings from that run are documented in docs/https-setup.md:
NPM 2.15 dropped the default admin@example.com account for a setup wizard,
and its default 443 server now refuses connections without SNI -- which is
every connection made to a bare IP address. docker/npm-default-site.conf is
the opt-in way around that, mounted writable (a :ro mount aborts s6 init and
leaves nginx not listening at all).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 17:58:03 +02:00
MarekWoandClaude Opus 5 a3fde55704 feat(android): publish wrapper 1.2 with the text-size fix
Signed release build of the textZoom change from 09dcf26, plus the
download table it invalidates.

Verified on the artifact rather than on trust: classes.dex references
setTextZoom, the manifest reports versionName 1.2, and the signing
certificate is SHA-256 42:58:57:B3:...:D2:30 -- the same key as 1.1, so
it installs over the previous version instead of forcing a reinstall.
User confirmed on-device that nothing wraps any more.

Size is unchanged at 4.6 MB (4.61 MiB), so only the version and hash
rows move. android/README.md documents the release steps but pins no
version of its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 20:39:03 +02:00
MarekWoandClaude Opus 5 09dcf26f87 fix(android): match the browser's text size, and let the status bar wrap
Android's WebView scales text by the system font-size setting; Chrome
ignores it and uses its own accessibility preference. With the system
font one step above default, the wrapper rendered every string ~20%
larger than the website at an identical layout width -- the brand
truncated to "mc-web...", message bodies wrapped early, and the footer
broke apart. Pin textZoom to 100 so the wrapper matches the browser.

The footer was fragile independently of that. Status, region badge and
the "Updated:" time sat in a justify-content-between row with no wrap,
so once they exceeded the width each item broke internally instead of
the row reflowing -- a dot with "Polaczono" stranded below it, a time
split from its own label. Longer translated labels ("Aktualizacja:" vs
"Updated:") hit this well before English did. Adding flex-wrap plus
text-nowrap on the items makes them reflow as whole units; .badge
already carries white-space: nowrap, so it needs nothing.

Verified in Chrome against the local container, pl locale, region badge
forced visible, at 412/360/320 CSS px and with a 1.2x text-only override
that reproduces textZoom: before/after screenshots show the reported
breakage and a clean two-line reflow respectively, with no horizontal
overflow. At full width the change is a no-op.

Rides along in the already-pending, never-built wrapper 1.2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:46:18 +02:00
MarekWoandClaude Opus 5 4d2b3eb6be docs(i18n): state the catalog filename rule and where to install it
A catalog whose filename does not match ^[a-z]{2}(-[A-Z]{2})?$ is ignored
silently by available_languages() — no error, no log line, no entry in the
dropdown. pt-br.json, hu_HU.json and hungarian.json all fail that way, and the
guide gave no way to find out. Verified against the running container: hu.json
appeared as "Magyar" with three keys and the rest falling back to English,
while pt-br.json, hungarian.json and a de.json with a broken body were all
skipped without affecting the app.

Also spells out that app/translations/ lives inside the image and needs a
rebuild, so the config-directory drop-in is the one to reach for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 15:48:38 +02:00
MarekWoandClaude Opus 5 fbd6820df0 fix(i18n): finish repeater management, contacts, path analyzer (stage 9d)
An end-of-stage-9 sweep over every JS file found ~85 user-facing strings that
stages 3-5 never extracted, because their browser diffs only ever opened the
states those pages start in.

Most of it is repeater-manage.js: the entire login flow (prompt, progress,
failure hint, password modal), the reboot and radio-change confirmations, the
"Updated Xs ago" labels and fmtHeardAgo, the Status/Telemetry/Neighbours/CLI
tool chrome and the Status table's section headings and row labels. Plus
contacts.js (auto-cleanup status, push/move confirmations, bulk progress
counters, QR error), path-analyzer.js (map popups, table tooltips, the map
toggles) and two strings in repeaters.js.

Row labels in the Status table go through t(), not tHtml(): statusSection()
already runs esc() over the title and every label, so tHtml would escape twice.

Protocol terms stay English as before: flood/direct in the packet counters,
the CLI quick commands and the Cayenne LPP sensor type names.

Verified with F:\tmp\pwverify\i18n-stage9d.js. Two strings come back identical
and both are correct: "Hop" is glossary, and "Repeater not in list" is backend
error text, which is out of scope by decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 15:20:00 +02:00
MarekWoandClaude Opus 5 1ee7f0703d feat(i18n): translate the update flow, search and notifications (stage 9c)
Last slice of app.js: the update check/modal/progress, the browser
notification body (now plural-aware via tn), global search, the mentions
autocomplete, the archive dropdown, mark-all-read, and the four delete
confirmations plus two form errors that 9b's toast sweep walked past.

app.js is done. What is left in it is deliberate: the resync reason strings
(console only), "SNR: x dB", the node roles, and the RADIO_PRESETS labels —
region presets stay English per the glossary.

Verified with F:\tmp\pwverify\i18n-stage9c.js: the search modal in three
states, the update modal, the mentions empty row, and the notification /
confirm strings that never reach the DOM, resolved directly in both
languages. Every comparable string differs; the 80 identical ones in the
search hit list are message bodies and sender names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 12:11:32 +02:00
MarekWoandClaude Opus 5 8bd7ce6ab6 feat(i18n): translate Settings, the Device modal and the registries (stage 9b)
Second slice of app.js: the Device modal (Info rows, Stats table, Share tab),
Settings device/radio saves and the shared "Settings saved" toasts, contacts
cleanup, device commands, the PWA notification toasts, and everything app.js
renders into the Region, Analyzer and Observer registries plus the per-channel
region picker and the Backup/Optimize modal.

Radio parameter names stay English per the glossary (Bandwidth, Spreading
Factor, Coding Rate, Multi Acks), as do the node roles in the type column; the
generic row labels around them are translated. The five `x.error || 'Failed to
save'` fallbacks are normalised onto the existing common.save_failed rather
than minting a near-duplicate key.

Verified against the local container with F:\tmp\pwverify\i18n-stage9b.js,
which drives the three Device tabs, five Settings tabs and the Backup modal in
both languages: 12 identical strings, all of them device names, hex keys,
MQTT topic literals or a backup filename.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 11:19:31 +02:00
MarekWoandClaude Opus 5 75f8b4cfca feat(i18n): translate the message list, channels and map popups (stage 9a)
First slice of app.js: the map popups, resync and channel toasts, the message
bubble (action tooltips, SNR/hops/route meta, echo badge), the route popup,
channel management, and the two joins plus the image-preview modal in
message-utils.js.

Six keys shared with the DM panel move dm.* -> chat.*, the namespace stage 6
opened for chrome the two chats have in common; dm.js follows.

The action-button injection used to find already-rendered buttons with
[title="View in Analyzer"], [title="Edit message"] and [title^="Ignore"].
Those titles are translated now, so every lookup would have missed and the
meta refresh would have appended a second analyzer button on each pass. They
match .btn-msg-analyzer / .btn-msg-edit / .btn-msg-ignore instead.

Verified against the local container: F:\tmp\pwverify\i18n-stage9a.js reports
one identical string across the message list, route popup and channel modal
(an echo badge that is pure data), and i18n-stage9a-buttons.js confirms three
forced meta refreshes leave at most one of each button per bubble.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 11:04:29 +02:00
MarekWoandClaude Opus 5 88ca23bccb fix(i18n): finish the contacts templates and three Settings spinners
Stage 5a translated the contacts pages but the browser diff was only run
against /contacts/existing, so the other three kept most of their page
headers, action buttons and confirmation modals in English: page titles,
"Add Contact", "Pending Contacts", Refresh, Filters, Approve/Ignore and
both batch-confirmation dialogs, plus Advanced Filters, Preview Cleanup
and the cleanup confirmation on /contacts/manage. Most of the keys they
needed already existed from 5a - the template sites were simply never
wired to them.

Three spinners in the Settings modal (Regions, Analyzer, Observer lists)
were missed in 8b for the opposite reason: their containers are excluded
from the stage-8 browser check as JS-populated, but the spinner inside is
server-rendered, so excluding the container hid it.

Root cause of both, and the reason for the new tool: a browser diff only
sees what is visible on the page it is pointed at. Add
i18n-template-diff.py, which fetches each entry point's HTML in en and pl
and compares text nodes directly. No browser, no per-page script, and it
covers closed modals and every page in one run - it found all of the
above in a single pass. It cannot see anything JavaScript writes, so it
complements the Playwright scripts rather than replacing them.

Every server-rendered template in the project is now translated. The
whole-project run reports 11 identical strings, all correct: the device
name, version strings, the language names (which are supposed to match),
"1 hop" (identical in Polish) and the glossary terms Hash, HB, Repeater.

Catalog: 760 -> 781 keys, pl at 100%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 10:29:30 +02:00
MarekWoandClaude Opus 5 55737fa8c6 feat(i18n): translate Settings - Appearance, Contacts, Regions, Analyzer,
Observer, Notifications (stage 8b)

Finishes the Settings modal, and with it every template in the project.

The Quick Access placement table is 13 rows that differ only in the item
they name, so it is driven by one template key plus the item labels the
menu and FAB column already have - t('settings.appear.placement_aria',
item=t('dm.title')) - instead of 13 near-duplicate keys and 26 copies of
"Quick Access"/"Main Menu".

That does normalise two aria-labels: "DM placement" and "Contacts
placement" become the full item names, matching what the row already
reads and what the other eleven already did. They are screen-reader-only,
so nothing visible changes; the alternative was keeping two keys alive
forever to preserve an abbreviation.

Paragraphs with a link or <code> inside stay whole rather than being cut
around the markup - the Regions and Observer intros are single t_html
values, so a translator moves the link where their grammar wants it
instead of translating two half-sentences.

Also in app.js: updateNotificationToggleUI() writes all four badge states
(Unavailable / Blocked / Enabled / Disabled) over the server-rendered one.

Catalog: 715 -> 760 keys, pl at 100%. The Settings diff now reports two
identical strings across all ten tabs, both correct: docs/translations.md
and meshcore-packet-capture, a path and a format name. No horizontal
overflow at 360px on any tab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 10:15:41 +02:00
MarekWoandClaude Opus 5 0a99a7b6e7 feat(i18n): translate Settings - Device, Messages, Group Chat, Interface (stage 8a)
The modal shell and the first four tabs, plus all ten tab labels: the tab
bar is one block, so splitting it across two commits would have meant
editing the same lines twice. 8b fills the panes behind the other six.

Radio parameter names stay English - Bandwidth, Spreading Factor, Coding
Rate are what the firmware, the CLI and the forums call them, the same
call rptmgmt.set.radio_note already made. Latitude, Longitude and "Pick
from map" reuse the rptmgmt.set.* keys rather than getting a second
translation that could drift from the repeater form.

"Auto-close after (s)" and "Don't close automatically" appear in both the
Group Chat and Interface tabs with identical English, so they share one
key; only their tooltips differ, and those are separate keys.

Also in app.js: populateRadioPresets() rebuilds the preset <select> and
was writing "Load preset..." back in English over the translated option.

Catalog: 651 -> 715 keys, pl at 100%. Verified with a new
i18n-stage8-settings.js that walks all ten tabs (and both Device pills),
written to cover 8b too - the six untranslated tabs show up in its report
until their slice lands, which is the point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 10:06:02 +02:00