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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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
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
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
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
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
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>
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>
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>
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>
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>
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>
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>
Manage Channels, region scope picker, Share Channel, Device Info,
analyzer registry (edit + chooser), Observer MQTT broker editor,
coordinate picker, quote dialog, map, update, backup and search.
The Settings modal (base.html ~458-1147) is still untouched - stage 8.
Where the two share markup, this commit anchors on the container id
rather than the markup: the "Loading..." spinner appears six times in
base.html and only three of them are in this slice.
Also in app.js, on the same rule as 7a - the writer that replaces a
string this slice keyed: the analyzer and broker modal titles (they
swap Add/Edit on open), the map modal title, the coordinate picker
label, and the database size line under Optimize.
Three real misses came out of the browser diff, none of which the
checker could see:
- Cancel/Save in the analyzer and broker editors were never keyed. They
only surfaced once because the diff dedupes by text, so a single
report line stood for both modals.
- The database size under Optimize is written by JS on open, so the
translated server-rendered value was replaced a moment later.
- All three Device Info tabs render their bodies from app.js. Excluded
from the check and left whole for stage 9 rather than translating the
Share tab alone.
Left English on purpose: FTS5 query examples (hello OR world, hell*) -
the syntax is typed verbatim in any language, only what each one means
is translated - plus mqtt.example.com and the {packetHash} token.
Catalog: 573 -> 651 keys, pl at 100%. Verified with a new
i18n-stage7b-modals.js that opens each modal through the Bootstrap API
instead of its real trigger, since several are only reachable from a row
or message that may not exist on a given device.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Covers app/templates/dm.html and app/static/js/dm.js: the sidebar, the
searchable conversation selector, the composer, message bubbles and their
delivery meta, the Contact Info modal, path management, and the repeater
list/map pickers.
The DM panel's path management and repeater pickers are the code the My
Repeaters panel was adapted from, so its strings reuse the repeaters.*
keys added in stage 3a rather than duplicating them. Shared chat chrome
(filter bar, FAB titles, composer placeholder, status bar) goes to a new
chat.* namespace so index.html and app.js can reuse it in stages 7 and 9,
and console.status.* is promoted to common.connected/disconnected/
connecting now that two status bars want the same three words.
formatRelativeTimeDm() was the last of the four local relative-time
implementations; it is gone, folded into formatTimeAgo() from
datetime-utils.js. The contact-info row uses the long form to match the
contacts page, the map popup the short form to match My Repeaters.
Also fixed along the way, both found by grepping this slice's shared
strings against the already-translated panels:
- repeaters.js reset the map picker label from JS in three places with a
hardcoded English string, so the header translated on load and flipped
back to English the moment the picker opened (missed in stage 3a).
- tHtml() on a plural key always renders the "one" form, because only
tn() passes the count to the resolver. That made the repeater picker's
shared-prefix tooltip read "3 repeater ma ten prefiks". Fixed in both
callers and now an error in scripts/i18n_check.py, which had no way to
catch it.
Left English on purpose: Flood/Direct/FLOOD and the SNR labels (glossary),
and the 'Device path' label written to the DB on import - it is stored
data, so translating it would freeze one language into the row.
Catalog: 471 -> 526 keys, pl at 100%. Verified against the local
container with i18n-diff.js plus a new i18n-stage6-dm.js that drives the
panel through each modal, since the diff tool cannot see hidden ones.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
~100 keys across repeaters.html and repeaters.js: the list, the add picker, the
password/login flow, the path editor, the map picker, and every toast.
Boundary call made here and written into docs/translations.md: "path" is
translated ("ścieżka"), because it is a word the user reads, not a value the
device reports. The mode names it can hold — Flood, Direct, FLOOD — stay as-is,
as do hop hex, repeater names and login roles. The rule is now stated explicitly
in the translator guide, since "Skonfigurowane path" is exactly the kind of
half-translation that makes a UI worse than leaving it in English.
Removed a third copy of relative-time formatting. Stage 0b merged app.js and
dm.js's formatTime but I grepped for the wrong names and missed
formatRelativeTime; repeaters.js had its own, with a "Never" branch that was
unreachable because its one call site already guards a falsy timestamp. It now
uses the shared formatTimeAgo. contacts.js and dm.js still carry their own
copies — noted in place, folded in with stages 5 and 6.
Two checker fixes, both found by this slice:
- Keys chosen inline, tHtml(x ? 'a.b' : 'c.d'), were invisible to the scanner.
That produced false "unused key" warnings and would have silently hidden a
typo in either branch. Added a second pattern for that shape.
- The generic en/pl diff caught two real misses I had made — the page <title>
and the toast header, both still reading "My Repeaters". Swept every other
template for the same pattern; the remaining ones belong to later stages.
Verified: checker clean, and the diff now reports every comparable string on
/repeaters as changed between en and pl.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First vertical slice: /logs chrome in English and Polish. Small on purpose —
~13 strings — but it exercises every part of the mechanism end to end: the head
include, the catalog route, the cookie, server-rendered t()/tn(), client-side
t()/tHtml()/tn(), a JS-rendered plural, and a rebuilt <select>.
Translated: title, pause/clear tooltips, the two "All ..." filter options, the
search placeholder, the reset tooltip, the loading and load-failed messages, and
the entry counter.
Deliberately NOT translated, per the boundary in docs/translations.md: the log
lines themselves and the DEBUG/INFO/WARNING/ERROR level names. Those are
protocol-side identifiers — the level filter also compares against them, so
translating the labels would be a step toward breaking the filter. Marked with a
comment in the template so the next person does not "fix" it.
The counter is the interesting case: English has two plural forms and Polish
four, so "100 entries" is "100 wpisów" while "2 entries" is "2 wpisy". Rendered
by tn() through Intl.PluralRules on the client and the matching rule table on
the server, which agree.
Also fixed in scripts/i18n_check.py: it warned when a markup-free value was used
via tHtml(), which is wrong and actively harmful advice. What tHtml() buys at an
innerHTML sink is param escaping, not markup support — the warning would have
pushed people toward t() in exactly the place where t() is the XSS hazard. The
error in the other direction (markup used via t()) stays. Output streams are now
forced to UTF-8 so `--missing pl > todo.txt` produces a usable file on Windows
rather than mojibake.
Verified in the browser: every chrome string differs between en and pl, level
names unchanged, no catalog key leaked into the page, the count stays translated
after filtering, and the filter bar wraps without overflow at 360px in Polish.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Groundwork for translating the interface. No strings are extracted yet — this
stage only adds the mechanism, so the UI is byte-identical in English.
Translations are one flat JSON file per language. An admin can add a language by
dropping <lang>.json into $MC_CONFIG_DIR/translations/ and picking it in
Settings > Appearance — no rebuild, no restart, no compile step. A drop-in file
overrides a built-in one of the same name. Built-ins: en (source) and pl.
How it works:
- app/i18n.py loads and merges catalogs over en.json, so per-key English
fallback is baked in server-side and the JS runtime needs no fallback logic.
Catalogs are fingerprinted with stat() per render, so a dropped-in file is
live on the next refresh rather than the next restart — which matters,
because restarting drops the device connection for up to 60s.
- Template text renders server-side via t()/t_html()/tn() from inject_globals(),
so the first paint is already correct. That context processor covers every
render_template() in the app, including the six standalone iframe pages, so
routes/views.py needed no changes.
- JS text comes from /i18n/<lang>.<hash8>.js, a blocking immutable script shared
by all 8 entry points. The hash is in the path, not a query string, so
intermediary caches and the service worker bust reliably.
- Language = per-browser mc_lang cookie over a server-wide DB default. The
cookie is what makes iframes work: they are same-origin, so they send it
automatically and the existing modal-open reload wiring needs no changes.
t() deliberately does not escape — Jinja autoescape handles that, and escaping
here would double-escape every French apostrophe. t_html()/tHtml() escape their
params but trust catalog markup.
Also fixed along the way:
- save_ui_settings() replaced the whole settings blob instead of merging, so a
language-only POST would have wiped the toast settings. It worked before only
because the one form always submitted every key.
- `const t = document.getElementById(...)` in populateUiSettingsForm shadowed
the global translation helper; same for `var t` in six theme IIFEs.
scripts/i18n_check.py now fails the build if that pattern comes back.
- Removed app/templates/contacts.html, dead since the contacts pages were split
(no render_template reference anywhere), and two stale CSS comments.
Verified locally: catalog route headers/security, drop-in without restart,
malformed catalogs logged and skipped rather than offered in the picker,
cookie propagation into iframes, and 360px layout. Python and JS agree on
interpolation, escaping and Polish plural categories.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dropdown already rendered a .sidebar-unread-badge element, but the
styling rule was scoped to .channel-sidebar-item, so on narrow screens the
count fell back to plain text in the row's own colour. Extend the selector
to .channel-selector-item so both views share one badge style.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three tabs open made every endpoint answer in ~20s, which two sessions read as
contention on a shared server-side lock. It wasn't the server. With 3 tabs open,
/health - which touches neither the database nor the device - measured a 14.7s
median from inside a tab and 11ms from a client outside the browser at the same
instant. The server was idle throughout.
Socket.IO clients pinned transports: ['polling'], upgrade: false, so each tab
held one HTTP connection open for its whole lifetime. Browsers allow six
concurrent HTTP/1.1 connections per origin, shared across every tab, so three
tabs consumed the pool and everything else queued in the browser waiting for a
free connection. /proc/net/tcp in the container confirmed it: pinned at exactly
6 established connections, unmoving.
The pin dates from 1d47c9c, when werkzeug had no WebSocket support and every
upgrade attempt returned HTTP 500. python-engineio==4.8.1 (pinned five weeks
later, in d3590f9) pulls in simple-websocket, which fixed that; the workaround
had outlived its premise. Drop it and use the default transports, which open on
polling and upgrade. A WebSocket is not part of the HTTP pool, so the pool is
released. Where the upgrade is blocked - a proxy that drops the Upgrade header -
the client stays on polling by itself, which is exactly today's behaviour.
Measured with 3 tabs, in-page medians: /health 14664ms -> 12ms, /api/status
19282ms -> 64ms, both now matching what the same probes read from outside the
browser. All three tabs report transport "websocket", server pushes still
arrive over it, and the log no longer fills with "Session is disconnected"
(0 occurrences, 0 tracebacks across the run).
Also corrects the earlier diagnosis in the docs: the per-endpoint timings that
looked like a lock were measured request->requestfinished in the browser, which
includes connection-queue time, so every endpoint flattened to the same figure
regardless of its own cost.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/api/status is polled by every open page (on load, every 60s, and on
visibility resume) and cost ~266-450ms in-container while doing no device
I/O at all — check_connection() is just an attribute read, and /health
returns in ~1ms, so all of it was SQLite.
Two calls were over-fetching:
- get_stats() ran COUNT(*) over 11 tables to answer a question about 2.
COUNT(*) FROM echoes alone was ~200ms over 19k rows and the result was
discarded. That table only grows, so the endpoint kept getting slower.
- get_channel_messages(limit=1) was a SELECT * fetching every column,
raw_packet included, to read one timestamp. ORDER BY timestamp has no
usable index (idx_cm_channel_ts leads with channel_idx), so the plan
was a full SCAN through two temp B-trees. MAX(timestamp) is served off
that index as a covering scan instead.
Replaced with Database.get_status_summary(): three scalar subqueries on
one connection. ~371ms -> ~46ms for the DB work; endpoint median ~57ms
in-container, ~78ms from the page. Response verified byte-identical.
This does not address the multi-tab congestion — at one poll per tab per
60s /api/status was never that cause — but it removes a recurring cost
and re-baselines the probe those measurements are taken with.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Testing after the batching fix showed three tabs still push /api/status from
~600ms to a ~13s median, from a different cause (every endpoint degrades
together under load, so a shared lock rather than a request storm). The entry
claimed multiple windows were no longer a problem; scope it to the load
reduction that was actually measured.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every incoming echo triggers a sweep of the whole rendered message list, and
refreshMessagesMeta() awaited one /api/messages/<id>/meta per message inside
the loop. Messages that never gain a route (nothing heard them) never stop
qualifying for the sweep, so the same ~180 messages were re-fetched every few
seconds: 7,500 requests in nine minutes on a single tab, each opening its own
SQLite connections. The single-threaded werkzeug server — the same one
production runs — had no room left for anything else, so the UI hung on
"Loading messages..." / "Connecting..." while the device was in fact connected.
Add GET /api/messages/meta?ids=... resolving the whole sweep with a handful of
queries, batching the row and echo lookups, and have the client collect ids
first and fetch them in chunks. The per-message endpoint stays for forced
single refreshes; both now share _build_message_meta() and the existing
_build_channel_secrets / _get_row_pkt_payload helpers, so the payload is
unchanged (verified byte-identical against the old response).
Measured on the local container, 500 messages rendered / 181 needing meta:
one 273 ms request in place of 181 sequential ones at ~82 ms each.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The chat view only ever grew by socket push, so anything that arrived
while the connection was down was never drawn. Android tears the
connection down behind a locked screen, and the wrapper keeps the same
page alive for days, so the list stopped at the last message that got
through until the app was force-stopped. A browser tab hid the same bug
by reloading the page on resume.
Every way back from a gap now re-reads the list from the server: the
socket reconnecting, the page becoming visible after more than a glance
away, a heartbeat noticing its own tick arrived far too late (the page
was frozen), a new Refresh item in the menu, and window.__mcAppResumed,
which the wrapper calls from onResume since a WebView is not guaranteed
to report the page as hidden at all. Direct messages get the same
treatment.
Verified in Chrome against the local container: all five triggers fire a
resync, with no page errors and the list intact afterwards.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Also corrects the app-vs-PWA table in the user guide: it claimed QR
scanning and file downloads were unavailable in the Android app, which
contradicted the Android App guide from the start - both have always
worked there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The signed release build of the notification work from 954e99b, signed
with the same key as 1.0 (certificate SHA-256 425857b3...d230), so it
installs straight over the previous version and users keep their saved
server address.
Verified against the packaged APK: versionCode 2 / versionName 1.1,
POST_NOTIFICATIONS present, and assets/notification_shim.js included.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Android's WebView ships no Web Notifications API, so window.Notification
was undefined, mc-webui detected that and greyed its toggle out as
"Unavailable". The app also declared no POST_NOTIFICATIONS and created no
channel, so it never even appeared in Android's notification settings.
A shim injected at document start puts window.Notification back and
forwards it to a @JavascriptInterface bridge that posts through Android's
NotificationManager. mc-webui itself is untouched - the page keeps using
the standard API.
- onPageStarted is early enough: mc-webui reads the permission on
DOMContentLoaded, a whole parse and script pass later
- web permission states map onto Android's, with
shouldShowRequestPermissionRationale separating "ask again" from
"blocked for good" after a refusal
- tags replace notifications the way the web API expects; a tap returns to
the running app (singleTop) and fires the page's onclick
- notifications only arrive while the process is alive, same as the PWA
versionCode 2 / versionName 1.1. Verified: debug and release both build
clean (lintVitalRelease included), shim and drawable land in the APK, and
the shim's contract is covered by a Node harness against a fake bridge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the debug APK with a release build signed with the project key
(CN=Marek Wojtaszek, valid to 2081, v1+v2 schemes, not debuggable), so
every later version installs over this one instead of forcing a reinstall.
Docs catch up with what the app can now do: QR scanning works on https
instances, downloads land in the phone's Downloads folder, and the address
form is a non-destructive screen you can reach deliberately. New SHA-256,
size, permission list and the signing fingerprint to verify against.
Also silences the Kotlin warning for the deprecated shouldOverrideUrlLoading
overload kept for Android 5.x - source-only, the APK is unaffected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>