Commit Graph
856 Commits
Author SHA1 Message Date
MarekWoandClaude Opus 5 59ef0dd6be fix(ui): explain a non-JSON answer instead of printing its parse error
An instance published behind an access list or Cloudflare Zero Trust gets
an HTML login or error page in place of JSON once that session expires.
`resp.json()` then throws "Unexpected token '<', "<!DOCTYPE "...", and the
repeater panes printed that at the user. Diagnosed on the production
server 2026-09-08: the origin had answered a clean 504 JSON ("No reply
from repeater within 10s") and nginx passed it through untouched, so the
HTML could only have come from in front of it.

New window.fetchJson() (fetch-utils.js, loaded from _head_i18n.html so all
eight entry points get it) returns the parsed body — `data.success` and
`data.error` checks are unchanged — and reports anything else in the same
shape the API uses: {success:false, error, http_status, non_json|offline}.
A 401/403 with a non-JSON body is named as an expired login, since that is
the case the user can act on; an error the API sent as JSON keeps its own
message. 88 call sites converted; those that deliberately inspect the
response (resp.ok/status, blob, uploads) still use fetch directly.

Also stops three catch blocks in the repeater panes from rendering the raw
exception text.

Verified in a browser: normal calls unchanged, an HTML answer comes back
as a readable sentence, a refused connection as a network error, and all
seven pages load with no console errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 11:47:31 +02:00
MarekWoandClaude Opus 5 251d78d6bf feat(chat): label the message meta line with icons instead of words
"SNR | Hops: 4 | Route (5): 2 bytes | Region: pl" took two rows on a
phone even without a region, and the words carried nothing the icon and
its tooltip don't. Hops, route and region now show as an antenna,
a signpost and a map pin, each with the word it replaces as its title;
the SNR keeps its label, being short already. The icons match the rest of
the UI: bi-signpost-split is the project's path icon and bi-pin-map the
status bar's region indicator.

The hash size joins them as "2B" — the SI/IEC symbol, upper case for byte
— with the spelled-out "2 bytes" kept for the tooltip. Each fact is also
nowrap, so "(5) 2B" can no longer break across rows mid-value.

On a 390 px phone nothing wraps any more: 0 of 500 Public messages and 0
of 11 on a channel carrying regions, where before every region message
took two rows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 08:25:23 +02:00
MarekWoandClaude Fable 5.1 56c4ff7eb5 feat(chat): keep SNR and hops in front of the route line
They describe the copy the node decoded and arrive with the message
itself, so they are there even when no echo was heard — otherwise such a
message had nothing under it at all — and a hop count at a glance says
whether the routes popup is worth opening. The rest stays: hash-size
label, region, and the popup with the Direct entry and the received
marker.

The popup's last-resort placement changed too: when neither edge of the
tap target keeps a wide popup inside a phone's viewport, it is pinned to
the viewport's left margin instead of overflowing on the right.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 21:15:37 +02:00
MarekWoandClaude Fable 5.1 fa622baf21 feat(chat): drop SNR/hops from the message line, complete the routes popup, colour the hash size
The line under an incoming channel message showed the SNR and hop count of
the one copy the firmware decoded, while the popup listed only the copies
relayed by repeaters. With the sender in direct range that read as a
contradiction ("Hops: 0" above, "Hops: 1" on every popup entry), because
the direct copy has an empty path and was filtered out of the echo arrays.

The line is now just "Route (n): <hash size> | Region: <name>", and the
popup carries everything it used to: the echo arrays include every copy
heard, in the order heard (ties on the 1 s received_at broken by id), a
direct copy is listed as "Direct" with 0 hops and no copy/analyzer action,
and the copy the node actually decoded is marked "received" — matched on
hop count and SNR against the message row, so a copy missing from the RX
log never mis-marks the next one.

The hash size is coloured red / orange / green for 1 / 2 / 3 bytes,
semi-bold, with per-theme tokens kept at or above 5:1 on the bubble; the
whole line goes from 0.7 to 0.8 rem and --text-meta from ~2:1 to 4.5:1,
since it was hard to read on a big screen. The popup now opens to the
right of its tap target: with the route first in the line, a
right-anchored popup ran under the channel sidebar on a desktop.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 17:28:39 +02:00
MarekWoandClaude Fable 5.1 0c94bf0ee5 feat(chat): show the region of a channel message and shorten its route line
The meta line under an incoming channel message used to print the first
route inline ("Route (3): 142C→1122→271C→EE6C"), which with 2- or 3-byte
hashes and a few hops crowded everything else off a phone screen. It now
names only the per-hop hash size ("Route (3): 2 bytes") and stays the tap
target for the routes popup, which lists every route as before.

The line also ends with "Region: <name>" when the packet was flood-scoped
to a region this instance knows. The region name never travels: a
TRANSPORT_FLOOD packet carries a 16-bit transport code (HMAC of the scope
key over the payload) that _on_rx_log_data used to discard. Echoes now
keep those 4 header bytes in a new echoes.transport_codes column, and the
API recomputes the code for every configured region (Settings → Channels)
to find the one that reproduces it; own messages fall back to their
raw_packet snapshot. calc_transport_code() is shared with the raw-resend
packet builder, so both sides derive the stamp from one place.

Verified against on-air packets from the test server on #mcwebui: pl,
pl-ma and pl-kra each resolved to exactly one configured region.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:58:01 +02:00
MarekWoandClaude Opus 5 b1f1992bd8 chore: open 2.18.0 development
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 13:14:27 +02:00
MarekWoandClaude Opus 5 fb4159ca14 chore(release): finalize 2.17.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v2.17.0
2026-09-07 13:14:00 +02:00
MarekWoandClaude Opus 5 1797e6b7fc refactor(analyzer): move the map overlay toggles to the bottom-left
Top-right now holds only the maximize button, and the toggles no longer
sit over the hop labels that cluster near the top of a drawn route.
Bottom-left was the last free corner: zoom is top-left, attribution
bottom-right. Verified at 412x915 and 1440x900, both themes - 10px from
both edges, no overlap with the attribution.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 13:00:55 +02:00
MarekWoandClaude Opus 5 e0c4e48d88 feat(analyzer): maximize the map to the whole screen
On a phone the map view gives the map a fixed ~45vh strip under the
message list, which is too small to read a multi-hop route on. A control
in the map's top-right corner now expands #paMap over the frame and asks
the parent to hide the modal header, so the map really does get the whole
screen; the same button becomes a labelled Close, and Escape works too.

CSS overlay rather than the Fullscreen API: the panel runs in an iframe
and inside the Android WebView, where requestFullscreen on a plain
element is unreliable. The position rule needs !important — Leaflet
writes position:relative on the container as an inline style.

Verified live at 412x915 and 1440x900, both themes, in the modal, as the
standalone page and through a chat deep link: the map fills the frame,
the drawn route and the panned viewport survive both transitions, and no
console errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 12:52:34 +02:00
MarekWoandClaude Opus 5 77f6a70b97 feat(repeaters): move a region in the hierarchy
Adds "Move to another region" to the per-region menu. Until now the only
way to re-parent was to delete the region and add it again under a new
parent. `region put <name> <parent>` on an existing name already
re-parents it (RegionMap::putRegion), so the command was there; what was
missing was doing it safely.

Two firmware behaviours make a blind put unsafe, both confirmed live on
MarWoj Mobile Observer, and both settled by the one tree read the move
now does up front:

- The only cycle check is `region->id == parent_id`. Moving a region into
  one of its own descendants is accepted, and detaches that branch from
  the wildcard root: it disappears from the `region` listing entirely
  (printChildRegions recurses down from the root) while still holding
  table slots. Verified — after `region put zza zzb`, both vanished from
  the tree while `region get zza` still answered ` zza (zzb) F`. Nothing
  in the UI could then show them, let alone fix them. Forwarding is not
  at risk: findMatch scans the flat array and never walks parents.
- The put branch sets `region->flags = 0`, so moving a flood-denied
  region silently re-allows flood, and the reply says
  `OK - (flood allowed)` without hinting anything changed. Verified: zzb
  was denied before the move and came back allowed.

So the move reads `region` first, refuses self/descendant/wildcard
targets, then puts, then re-applies `denyf` when the region was denied.
A lost restore is surfaced rather than swallowed — the region has moved
but is forwarding traffic it was set to drop. The picker filters the same
descendant set client-side, so the guard is not a surprise at the end.

Moving a region carries its nested regions with it, which is the point:
verified moving zzc (holding zzb) under zza.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 11:20:43 +02:00
MarekWoandClaude Opus 5 328e1a3998 feat(repeaters): nest regions from the Add form
The backend already accepted a parent (`region put <name> <parent>`) and
the list indented nested regions correctly, but the Add form always
created at the root. It now carries an "Inside" picker listing the known
regions, defaulting to top level.

Verified end to end against MarWoj Mobile Observer: `zzpar` at the root,
`zzkid` inside it, the tree coming back as `* / pl / zzpar / zzkid` with
the child indented one level, and the firmware refusing to delete a
parent that still has children (`Err - not empty`).

Also fixes a status code this endpoint got wrong from the start. A
repeater answering `Err - not empty` or `Err - unknown region` is
enforcing its own rule, not failing as a transport, so it now comes back
200 with `ok: false` like the sibling /action and /neighbours/remove
endpoints, rather than 502. 502 was misleading and put a spurious error
in the browser console, where it could mask a real one. `dirty` is now
computed from `ok`, so a refused action can no longer mark the map as
having unsaved changes; the frontend checks both through one helper.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 10:52:23 +02:00
MarekWoandClaude Opus 5 538976ab1b feat(repeaters): manage regions from Settings, not just the CLI
A repeater's flood-scope map was only reachable by typing `region`
commands into the console and reading back an indented text tree.
My Repeaters -> Settings -> Regions now renders that tree as a list with
per-region actions, an Add form, and a Default scope card.

Placed as a row at the foot of the Settings pane rather than a seventh
tile: region management is a list with its own add/remove and an
explicit save, not the scalar get/set fields the accordion is built for.
openToolPane() already accepts any descriptor, so REGIONS_TOOL reuses
the pane machinery; _paneCurrent/_paneParent make the pane back arrow
return to Settings instead of jumping two levels to the grid.

Two firmware facts shape the design, both verified against a live
repeater (MarWoj Mobile Observer, 2.2.15):

- put/remove/allowf/denyf mutate the map in RAM only and are lost on
  reboot until `region save` writes flash. Edits therefore stage behind
  an explicit Save with a warning banner, and leaving with changes
  pending asks first. `region default` is the exception -- the firmware
  calls saveRegions() itself -- so it never sets the dirty flag.
- The bare `region` reply is an indented tree (indent = depth, `^` =
  home, trailing ` F` = flood allowed) capped at 160 bytes by
  exportTo(reply, 160). Suffixes are stripped right-to-left because a
  region may legitimately be named `F` or end in `^`, and a clipped
  tail is reported rather than hidden.

The binary AnonReqType.REGIONS request stays unused: it answers with
exportNamesTo(), a flat list of allowed names with no hierarchy, deny
flags or home marker -- most of what this screen shows.

Names go through the existing is_valid_region_name() before any command
is built, which also blocks CLI injection through a name.

28 new unit tests cover the tree parser (nesting, the `F`-named-region
ambiguity, `#` stripping, truncation) and the command builder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 10:37:38 +02:00
MarekWoandClaude Opus 5 963acb9fed chore: open 2.17.0 development
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 07:43:34 +02:00
MarekWoandClaude Opus 5 f566caab4a chore(release): finalize 2.16.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v2.16.0
2026-09-07 07:43:05 +02:00
MarekWoandClaude Fable 5.1 04c4312a7f feat(notifications): per-channel notification profiles
A channel used to have two settings: every message or nothing. Now it has
three - muted, every message, or a named profile of rules (mentions me,
message contains, sender contains; any or all must match). A channel on a
profile counts only matching messages: the unread badge, the navbar bell,
the app-icon badge and the browser/Android notification all follow the same
rule. Direct messages are untouched.

- app/notify_profiles.py: rule matching (case- and diacritic-insensitive,
  same table as the chat filter), validation, storage in app_settings
- read_status.notify_profile column, written together with is_muted so a
  mute never leaves a profile behind; migration added
- /api/notification-profiles CRUD, PUT /api/channels/<i>/notifications;
  /api/read_status and /api/messages/updates report channel_notify_profiles,
  the latter counts a profiled channel's unread through the profile
- Manage Channels: the bell opens a menu (Off / Every message / profiles /
  Manage profiles...) instead of toggling; profiled rows get a green badge
- Settings > Notifications: profile list with in-use counts and an editor
- app.js evaluates the same rules for socket-delivered messages, and a
  state version guard stops an in-flight poll from undoing a fresh
  mute/favorite/profile change
- i18n en/pl (43 keys), docs (user guide, architecture, whatsnew), tests

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 20:17:40 +02:00
MarekWoandClaude Opus 5 23c457d0cf chore(android): publish the apk carrying the page wake
Built from 8b5ed4c and run for five hours on a Galaxy S20 FE: 115 wake ticks,
two of which found the socket dead and put it back, and notifications still
arriving at the end. The previous upload predates the wake, so the checksum in
the docs pointed at a build that still went silent after an hour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 18:44:40 +02:00
MarekWoandClaude Opus 5 8b5ed4ceb5 fix(android): nudge the page, because nothing else will
Measured on a Galaxy S20 FE with the service running: the app process, the
service and the WebView's own renderer process all stayed alive for an hour and
a half, and the page still stopped after 56 minutes. Notifications were posted
correctly from 11:29 to 12:25 - 45 of them, traced through the PendingIntent
each one creates - and then nothing, while the server went on emitting messages
at 12:28:46, 12:28:53 and 12:29:12. The server saw the client time out at
12:29:52 and no reconnection was attempted for the next 29 minutes, although
socket.io retries forever by default. Delivery came back only when the activity
was resumed.

Ruled out with evidence rather than reasoning: Doze (the phone was charging and
only reached full at 13:05), Wi-Fi (wpa_supplicant heartbeats every 10s, no
drop), Samsung's Freecess freezer (it froze a dozen other apps by name that
afternoon and never this one - the foreground service did its job), a killed
renderer or app process (neither appears in any kill line), and socket.io giving
up (reconnectionAttempts defaults to Infinity).

What is left is that Chromium suspends the work of a hidden page. A foreground
service buys the process; it does not make Chromium run the page inside it. That
gap is not something the Stage B plan knew about.

So the service now nudges the page every two minutes. evaluateJavascript is the
lever: injected script does not queue behind the throttled timers the page's own
setTimeout calls sit in. The script re-opens the socket if it has died, which is
what mc-webui would have done a second later had its timers been running, and it
reads mc-webui's own chatSocket rather than anything added for this, so it works
against whichever server version the user typed in.

The wake logs one line every two minutes under the McPageWake tag. That is
deliberate: it is the only way to tell "the page answered" from "the page never
ran the script" after the fact, which is exactly what the next measurement has
to distinguish.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 13:31:40 +02:00
MarekWoandClaude Opus 5 d14ebd6994 chore(android): publish the 2.1 apk and its checksum
Built from 9adb98d and verified on a Galaxy S20 FE: installs over the Play
build as an update, keeps the saved address, and the foreground service starts
and stops with the notification toggle.

The Play row in docs/android-app.md deliberately stays at 2.0 - the Store still
serves that until the release passes review.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 11:26:54 +02:00
MarekWoandClaude Opus 5 9adb98dfdd fix(android): a chosen server starts fresh, without the last one behind it
Found on a real device: after Change server, Back did not offer the leave
prompt but reversed into the previous server's pages, leaving the app showing
one instance while it was set to another. The address form is reached through
Back, so switching twice was enough to get there.

The behaviour predates the recent-servers list, but that list is what turns
switching servers into an everyday action rather than a once-a-year one.

Only a deliberate choice of server clears the history - typed in, picked from
the list, or restored at startup. Following a notification into a conversation
still goes back the way it came.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 11:19:14 +02:00
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>
v2.15.0
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' v2.14.0 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' v2.13.0 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' v2.12.0 2026-08-27 16:04:08 +02:00