80 Commits
Author SHA1 Message Date
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 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 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 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 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 cd9a3cb397 feat(i18n): translate the remaining base.html modals (stage 7b)
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>
2026-08-01 09:54:02 +02:00
MarekWoandClaude Opus 5 3b34dd9646 feat(i18n): translate the DM panel (stage 6)
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>
2026-07-31 21:38:14 +02:00
MarekWoandClaude Opus 5 93c74d150f feat(i18n): translate the My Repeaters panel (stage 3a)
~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>
2026-07-31 18:49:13 +02:00
MarekWoandClaude Opus 5 05f502ee53 feat(i18n): translate the System Log panel (stage 1)
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>
2026-07-31 17:49:09 +02:00
MarekWoandClaude Opus 5 2ec2f461e2 feat(i18n): add UI translation infrastructure (stage 0)
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>
2026-07-31 17:29:41 +02:00
MarekWoandClaude Opus 5 44d14d7c34 chore(release): finalize 2.4.2
Only a fix since 2.4.1, so PATCH per the project's SemVer rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 13:07:36 +02:00
MarekWoandClaude Opus 5 20e1a67dc9 fix: show unread counts as badges in the narrow-screen channel list
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>
2026-07-31 13:06:05 +02:00
MarekWoandClaude Opus 5 6e180e8d45 chore(release): finalize 2.4.1
Only fixes since 2.4.0, so PATCH per the project's SemVer rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 09:37:08 +02:00
MarekWoandClaude Opus 5 fd054a95bd perf: let the realtime socket upgrade to a WebSocket
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>
2026-07-31 09:31:40 +02:00
MarekWoandClaude Opus 5 ab64ef72f2 perf: answer /api/status with one targeted query
/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>
2026-07-31 08:35:53 +02:00
MarekWoandClaude Opus 5 1e82a1ed7c docs: correct the multi-tab claim in the whatsnew entry
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>
2026-07-31 07:57:29 +02:00
MarekWoandClaude Opus 5 0e524ddbc1 perf: resolve message metadata in one batched request
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>
2026-07-31 07:44:48 +02:00
MarekWoandClaude Opus 5 ebd2e95fe1 fix: refill the message gap left by a backgrounded app
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>
2026-07-30 18:19:52 +02:00
MarekWoandClaude Opus 5 8695017af5 chore(release): 2.4.0
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>
2026-07-29 21:16:18 +02:00
MarekWoandClaude Opus 5 80eba5bbc1 feat(android): publish wrapper 1.1 with notification support
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>
2026-07-29 20:50:52 +02:00
MarekWoandClaude Opus 5 954e99b42c feat(android): give the wrapper working notifications
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>
2026-07-29 08:48:01 +02:00
MarekWoandClaude Opus 5 f5a797968b docs(whatsnew): link 2.3.0 with absolute URLs so the GitHub release resolves them
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 21:19:57 +02:00
MarekWoandClaude Opus 5 f6e427be5d chore(release): 2.3.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 21:19:13 +02:00
MarekWoandClaude Opus 5 28d74476e7 feat(android): publish the signed release build of the wrapper
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>
2026-07-28 21:17:57 +02:00
MarekWoandClaude Opus 5 0cbab03ab8 feat(android): add wrapper sources and fix the address, camera and downloads
The wrapper is what users sideload, so its source belongs next to the APK -
they can read what they install, or build it themselves.

Behaviour fixes on the way in (APK rebuild pending):

- The saved server address survives. Back on the first page and connection
  errors used to delete it, so a stray tap or a moment without signal meant
  typing the address again; the form now opens pre-filled and only a save
  replaces what is stored. Back at the top level asks: exit, change server,
  or cancel
- QR scanning works: the page's camera request is mirrored to an Android
  permission request (CAMERA, on an https instance - getUserMedia needs a
  secure context, as in any browser)
- Downloads work: a DownloadListener hands database backups and other files
  to DownloadManager, which puts them in the phone's Downloads folder
- Links to other hosts and non-http schemes open in the system browser, so
  a URL in a message no longer navigates the app away from the instance
- Rotating the screen no longer reloads the page

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 21:05:42 +02:00
MarekWoandClaude Opus 5 f71cac8b2e feat(android): ship the Android companion app with an install guide
A thin WebView wrapper that opens a user's own mc-webui instance full
screen, without the browser address bar. It asks once for the server
address and remembers it; all logic stays on the server.

- android/mc-webui-wrapper.apk (1.0, it.wojtaszek.mc.wrapper, minSdk 21)
- docs/android-app.md: download + checksum, "unknown sources" permission,
  the Play Protect notice, first connection, and the limitations that come
  with a WebView (no notifications, no QR camera, no file downloads)
- README, user guide and whatsnew entries, incl. an app-vs-PWA comparison
- *.apk marked binary so the text=auto rule can never touch it

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 20:55:04 +02:00
MarekWoandClaude Opus 5 0fc73ec760 chore(release): 2.2.0
Drop the -dev suffix and date the whatsnew section so release.sh can find
its notes. Also make the build-string example in the whatsnew header read
as an example, instead of naming one particular build that goes stale the
moment it ships.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 12:11:55 +02:00
MarekWoandClaude Opus 5 17398e8e41 feat(chat): quote with the '>' line prefix instead of guillemets
The Quote button produced `@[user] »text« ` — a shape mc-webui invented back
when it could not send newlines, and one that other MeshCore clients neither
write nor understand. It now writes the quote on its own line behind a '>'
and leaves the cursor underneath it, matching the plain-text convention some
users already type by hand.

processQuotes() styles both syntaxes, so messages sent by older builds keep
their formatting. The '>' match is anchored to a line start or a leading
@[mention] badge and looks for the escaped '&gt;', so neither "5 > 3" in
prose nor the brackets of generated tags can trigger it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 11:58:53 +02:00
MarekWoandClaude Opus 5 4c6857a00f feat: numbered releases (2.1.0) with git tags and GitHub releases
The app already knew exactly which build was running - a calendar version
of commit date plus short hash - but nothing gave a release a name users
could quote, and the repo had no tags at all.

Adds a VERSION file as the single source of truth for a SemVer release
number, read by app/version.py alongside the existing build string rather
than replacing it: the number is for people, the build is for pinning down
a deploy, and both ship in /api/version and the template context. The menu
shows the release first with the build underneath. #versionText still holds
the build string, because the remote-update poller compares it to detect
that the server came back on a new build.

Resolution order is unchanged (frozen file > git > fallback), and a frozen
file written before this change still yields a correct release number, so
an already-deployed server does not need re-freezing to stay sane. The
container has no git, hence COPY VERSION into the image - otherwise a plain
'docker compose build' reports 0.0.0.

scripts/release.sh cuts a release from main: it refuses a dirty tree, a
wrong branch, a malformed number or an existing tag, extracts the notes
from the matching whatsnew section, then tags, pushes and publishes via gh.

Numbering starts at 2.1.0 rather than 1.x: the v2 line has been in
production since March and 'v1' is the archived pre-migration branch, so
1.x would have been ambiguous. Sections in whatsnew before 2.1.0 keep
their date-only headings - they were never tagged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 19:20:57 +02:00
MarekWoandClaude Opus 5 95d96ecbd3 docs: Path Analyzer map overlays and remembered filters
user-guide: the Map view now opens showing only the selected route, with
the two opt-in checkboxes (All repeaters, Alternative paths) described
alongside how alternatives are coloured and why only their diverging
stretches are drawn. The Filters section gains a paragraph on settings
being remembered per browser, including the deep-link exception.

architecture: added map layer ordering, the segment de-duplication that
keeps alternatives visible, the no-refit re-render on toggle, and the
localStorage filter persistence contract (user-driven writes only, skip
restore on deep link).

whatsnew: three user-facing entries under the pending release section.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 18:47:38 +02:00
MarekWoandClaude Opus 4.8 4963dd6a32 docs: chat-route deep link into Path Analyzer; date the 2026-07-22 release
Cover the two changes on dev since the last main merge (c6d2367):
- d8d3427: clicking a route under a channel message now opens the Path
  Analyzer map deep-linked to that message + echo (user-guide new Group
  Chat Message Routes section, Path Analyzer to-open note, whatsnew New
  features, architecture deep-link URL contract).
- bb67c4d: exact sent-message echo matching fix (whatsnew Reliability).

Also finalize the previously-merged Unreleased-since-debb711 section by
dating it 2026-07-22 (the c6d2367 merge), and open a fresh
Unreleased-since-c6d2367 for the current changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 09:49:22 +02:00
MarekWoandClaude Opus 4.8 c5a0fa8fd1 docs: Path Analyzer Routes view + mobile polish, repeater login/location
Path Analyzer now documents its fourth view (Routes — consecutive
hop-segment stats with an "as path end" count) and the `>`-chained
sequence filter, the map's instant shortest-route draw and channel
label, and the mobile collapsible filter bar / 45vh map. My Repeaters
gains the Settings -> Location "Pick from map" picker and the
saved-password prefill on login retry; architecture.md adds the new
GET /api/repeaters/<pk>/password endpoint and a four-views note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 06:35:15 +02:00
MarekWoandClaude Fable 5 3af605504a docs: mention the combined 2/3-byte HB filter option
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 12:15:12 +02:00
MarekWoandClaude Fable 5 91b959aae4 docs: map path colors, numbered hop points, pick undo
Update the Path Analyzer map descriptions in user-guide.md and
whatsnew.md for the red numbered/name-labeled path points and the
reversible candidate assignments (per-hop undo + Reset picks).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:47:06 +02:00
MarekWoandClaude Fable 5 7bcd0ef46b docs: cover the Path Analyzer feature
- user-guide.md: new Path Analyzer section (opening, filters, the
  Messages/Repeaters/Map views, phone layout notes, local-view caveat);
  TOC entry; per-item placement action count 12 -> 13 with Path
  Analyzer added to the enumeration
- architecture.md: /api/path-analyzer/messages added to the Messages
  endpoint table plus a Path Analyzer design section (batched echo
  fetch, client-side filtering rationale, SNR attribution, hash ->
  contact resolution, legacy-row degradation)
- whatsnew.md: three user-facing bullets under Unreleased (the tool,
  the combined filters, phone support)

No deploy notes needed: no new dependencies, no schema changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 22:00:53 +02:00
MarekWoandClaude Fable 5 0d69947bfd docs: cover the My Repeaters (repeater administration) feature
user-guide.md gains a full My Repeaters section (adding repeaters,
saved passwords + the wrong-password/unreachable ambiguity, path
editing, and the six management tools) and fixes the stale Quick
Access placement list (now 12 items, real labels). architecture.md
documents the /api/repeaters/* endpoint family with its shared error
mapping, the repeaters DB table, and a DeviceManager subsection on
serialization, login sessions, CLI reply correlation, settings
batches and the actions whitelist. whatsnew.md adds three New
features bullets, a Reliability note (console login reports role),
and Deploy notes (meshcore>=2.3.7 rebuild, plaintext password
trade-off). rpt-mgmt.md now points to the built-in panel first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:33:27 +02:00
MarekWoandClaude Fable 5 e93961166c docs: cover the Observer (MQTT packet capture) feature
user-guide.md gains the Observer tab section (master switch, IATA code,
scheduled flood adverts, broker management, live badges) and the updated
Settings tab list. architecture.md documents the ObserverManager design
(hot path, per-broker paho clients, wire-format fidelity, reload without
restart), the observer_brokers table, the /api/observer/* endpoints and
the observer_status socket event. whatsnew.md announces the feature and
notes the paho-mqtt dependency (image rebuild via standard mcupdate) and
plaintext broker credentials.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 20:36:19 +02:00
MarekWoandClaude Opus 4.8 529b114104 docs: cover raw resend, touch-send, analyzer-as-row, and reliability fixes
Brings the docs up to date with the two undocumented batches on dev since
the last docs commit (d16093b). whatsnew.md is split by ship date: a
2026-06-26 section (already live on main) gains the raw channel-message
resend feature, the Edit-message button rename, and the log-loop /
connection-badge / failed-reconnect fixes; a new 2026-07-02 section covers
touch-device explicit send, DM line-break preservation, and the Letsmesh
analyzer becoming a normal editable row. A fresh Unreleased(since debb711)
opens on top.

architecture.md documents the channel_messages.raw_packet column, the
POST /api/messages/<id>/resend endpoint, the new /api/status fw/resend/
path-hash fields, the DeviceManager raw-resend + liveness-watcher
behavior, the werkzeug log-filter, and the analyzer seeding change.
user-guide.md adds a Message Actions section (Edit/Resend), the touch
Enter-vs-Send note in channels and DM, and rewrites the Analyzer tab to
reflect Letsmesh-as-normal-row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:14:03 +02:00
MarekWoandClaude Opus 4.7 d16093b459 docs: add whatsnew.md release notes for the upcoming main merge
User-facing summary of changes since fd2b3d0 grouped into New features
(custom analyzers, apply-path button, DB Optimize button, auto retention,
sluggish-device watchdog), Reliability & polish (polling-only fix for
load freezes, channel cache, scope-key refresh, multi-byte path
rendering, TCP self-heal, region scope on slots >7, console parser),
and a deploy note about restarting the host watchdog. Also linked from
user-guide.md's Getting Help section. File is meant to be refreshed
before each merge to main.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 12:04:19 +02:00
MarekWoandClaude Opus 4.7 53ef2759d5 docs: cover analyzer settings, vacuum/optimize, path apply, watchdog soft patterns
User-guide: new Settings > Analyzer tab (custom analyzer services with default/disabled
toggles and {packetHash} placeholder), apply-path upload button in DM Path Management,
Backup modal's Optimize button + live size label, console change_path now accepts
arrow/whitespace separators with consistent multi-byte chunk length and "path" output
shows hop count and byte size.

Architecture: new /api/analyzers CRUD + default endpoints, /api/db/size and the split
/api/db/vacuum kickoff + /api/db/vacuum/status polling (worker-thread VACUUM to survive
proxy idle timeouts), /api/contacts/<key>/paths/<id>/apply, /health and /health/strict
top-level routes, analyzers table and direct_messages.delivery_path_hash_size column,
recombined path_len byte storage. DeviceManager: per-send channel-secret refresh,
liveness telemetry (_last_rx_at + _consecutive_stats_failures), TCP self-heal via
_liveness_watcher_loop + in-place reconnect. Retention scheduler: on-by-default
90/90/60/30, post-cleanup VACUUM at >=1000 deletions, app-context wrapping, archiver
emoji-name fallback. Socket.IO clients forced to polling transport.

Watchdog: documented hard- vs soft-pattern detection (5 hits in 2 min for sluggish
get_stats / get_battery failures), pointer to /health/strict, and the systemd-restart
deploy note for scripts/watchdog/ changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 11:53:41 +02:00
MarekWoandClaude Sonnet 4.6 fd2b3d0f61 docs: cover sidebar sort/favorites, Quick Access placement, console transcript, sidebar breakpoint
User-guide: sidebar channels now sort by latest activity with favorites pinned first (star in
Manage Channels), Quick Access per-item placement + Hide switch in Appearance tab, new
Notifications settings tab, sidebar breakpoint in Interface tab, console change_path /
reset_path / 'direct' keyword, persistent transcript with jump-to-latest button.

Architecture: is_favorite in read_status, channels/favorites + console/output API endpoints.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 09:36:00 +02:00
MarekWoandClaude Opus 4.7 3ef1eac0be feat(console): rename to mc-webui, fix change_path, persist transcript
- Rename "meshcli Console" to "mc-webui Console" (modal title + docs).
- Drop redundant "Connected to..." messages; replace intro with a one-line "Type 'help' for available commands." hint.
- Use a teal device-name style so the header label is readable on the dark background.
- Display contact paths with commas (D1,90,05,54) instead of arrows in `contacts` and `path`, matching the standard MeshCore client.
- Fix `change_path`: previously read only args[2] after shlex split, silently writing a 1-byte path. Now joins remaining args, accepts comma/space/continuous-hex, validates hex, auto-deduces hash_size from comma-chunk length (1/2/3-byte hops), and routes through _change_path_async so path_hash_mode is set and the contacts cache is invalidated.
- Update `help` line and add a usage hint for the no-args form.
- Add capped persistent output transcript: GET/POST/DELETE /api/console/output (cap 500 entries). Console restores prior entries (faded) above a divider on open and exposes a trash button to clear it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 22:18:46 +02:00