Merge branch 'dev'

This commit is contained in:
MarekWo
2026-07-31 09:37:14 +02:00
13 changed files with 395 additions and 115 deletions
+1 -1
View File
@@ -1 +1 @@
2.4.0
2.4.1
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "it.wojtaszek.mc.wrapper"
minSdk = 21
targetSdk = 34
versionCode = 2
versionName = "1.1"
versionCode = 3
versionName = "1.2"
}
buildTypes {
@@ -106,6 +106,18 @@ class MainActivity : AppCompatActivity() {
if (savedUrl.isNullOrEmpty()) showConfig(null) else connect(savedUrl)
}
/**
* The page keeps running while the app sits in the background - that is how
* notifications keep arriving - but the connection behind it does not
* survive doze, so the message list can be minutes behind by the time the
* user looks at it again. A WebView is not guaranteed to tell the page it
* was ever hidden, so say it here instead and let mc-webui catch up.
*/
override fun onResume() {
super.onResume()
callJs("window.__mcAppResumed")
}
/**
* A notification tap on a running app lands here rather than in [onCreate].
* When the app was not running, simply being launched is the whole point of
+49
View File
@@ -834,6 +834,26 @@ class Database:
).fetchone()
return dict(row) if row else None
def get_channel_messages_by_ids(self, msg_ids: List[int]) -> Dict[int, Dict]:
"""Batch-fetch channel messages by id with chunked IN queries.
Returns {msg_id: row dict}; ids with no row are simply absent.
Chunked at 500 to stay under SQLite's host-parameter limit."""
result: Dict[int, Dict] = {}
if not msg_ids:
return result
with self._connect() as conn:
for i in range(0, len(msg_ids), 500):
chunk = msg_ids[i:i + 500]
placeholders = ",".join("?" * len(chunk))
rows = conn.execute(
f"SELECT * FROM channel_messages WHERE id IN ({placeholders})",
chunk
).fetchall()
for r in rows:
result[r['id']] = dict(r)
return result
def get_channel_messages(self, channel_idx: int = None, limit: int = 50,
offset: int = 0, days: int = None) -> List[Dict]:
with self._connect() as conn:
@@ -1460,6 +1480,35 @@ class Database:
# Maintenance
# ================================================================
def get_status_summary(self) -> Dict[str, Any]:
"""Message count + newest channel timestamp, for /api/status.
/api/status is polled by every open tab, so it gets a dedicated query
instead of reusing get_stats() + get_channel_messages(limit=1):
- get_stats() counts 11 tables to answer a question about 2. The
COUNT(*) over echoes alone costs ~200ms on a 20k-row table and its
result was discarded — and echoes only ever grow.
- get_channel_messages(limit=1) was a `SELECT *` whose ORDER BY
timestamp has no usable index (idx_cm_channel_ts leads with
channel_idx), so it scanned the table through two temp B-trees and
materialized every column, raw_packet included, to read one field.
MAX(timestamp) is served straight off that index instead.
Together with folding both into a single connection this is ~46ms
where the pair cost ~371ms.
"""
with self._connect() as conn:
row = conn.execute(
"""SELECT (SELECT COUNT(*) FROM channel_messages) AS channel_count,
(SELECT COUNT(*) FROM direct_messages) AS dm_count,
(SELECT MAX(timestamp) FROM channel_messages) AS latest_timestamp"""
).fetchone()
return {
'message_count': (row['channel_count'] or 0) + (row['dm_count'] or 0),
'latest_message_timestamp': row['latest_timestamp'],
}
def get_stats(self) -> Dict[str, Any]:
"""Get row counts for all tables."""
tables = ['device', 'contacts', 'channels', 'channel_messages',
+88 -68
View File
@@ -671,6 +671,88 @@ def get_path_analyzer_messages():
return jsonify({'success': False, 'error': str(e)}), 500
def _build_message_meta(row: dict, pkt_payload, echoes: list) -> dict:
"""Assemble the meta payload (SNR, hops, route, analyzer hash) for one row.
Pure: takes the already-resolved pkt_payload and its echoes so callers can
batch the DB work."""
path_len_raw = row.get('path_len')
hop_count = None
path_hash_size = 1
if path_len_raw is not None:
hop_count, path_hash_size, _ = decode_path_len(path_len_raw)
meta = {
'success': True,
'snr': row.get('snr'),
'path_len': path_len_raw,
'hop_count': hop_count,
'path_hash_size': path_hash_size,
'pkt_payload': pkt_payload,
}
if pkt_payload:
meta['packet_hash'] = compute_packet_hash(pkt_payload)
if echoes:
meta['echo_count'] = len(echoes)
meta['echo_paths'] = [e.get('path', '') for e in echoes if e.get('path')]
meta['echo_snrs'] = [e.get('snr') for e in echoes if e.get('snr') is not None]
meta['echo_hash_sizes'] = [e.get('hash_size', 1) for e in echoes if e.get('path')]
return meta
@api_bp.route('/messages/meta', methods=['GET'])
def get_messages_meta_batch():
"""Return metadata for many channel messages at once.
Query: ?ids=1,2,3 -> {'success': True, 'metas': {'1': {...}, ...}}
The UI sweeps every rendered message whenever echoes arrive; doing that one
request per message flooded the single-threaded werkzeug server (thousands
of round-trips per minute, each opening its own SQLite connections). This
resolves the whole sweep with a handful of queries.
"""
try:
db = _get_db()
if not db:
return jsonify({'success': False, 'error': 'No database'}), 500
raw_ids = (request.args.get('ids') or '').split(',')
msg_ids = []
for part in raw_ids:
part = part.strip()
if part.isdigit():
msg_ids.append(int(part))
if not msg_ids:
return jsonify({'success': True, 'metas': {}})
rows = db.get_channel_messages_by_ids(msg_ids)
channel_secrets = _build_channel_secrets(db)
payload_by_id = {
mid: _get_row_pkt_payload(row, channel_secrets)
for mid, row in rows.items()
}
echoes_by_payload = db.get_echoes_for_payloads(
list({p for p in payload_by_id.values() if p})
)
metas = {
str(mid): _build_message_meta(
row,
payload_by_id[mid],
echoes_by_payload.get(payload_by_id[mid], []),
)
for mid, row in rows.items()
}
return jsonify({'success': True, 'metas': metas})
except Exception as e:
logger.error(f"Error fetching batch message meta: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@api_bp.route('/messages/<int:msg_id>/meta', methods=['GET'])
def get_message_meta(msg_id):
"""Return metadata (SNR, hops, route, analyzer URL) for a single channel message."""
@@ -683,68 +765,9 @@ def get_message_meta(msg_id):
if not row:
return jsonify({'success': False, 'error': 'Not found'}), 404
pkt_payload = row.get('pkt_payload')
sender_ts = row.get('sender_timestamp')
ch_idx = row.get('channel_idx', 0)
txt_type = row.get('txt_type', 0)
# Compute pkt_payload if not stored
# Use DB channels (fast) to avoid blocking on device communication
if not pkt_payload and sender_ts:
db_channels = db.get_channels() if db else []
channel_secrets = {}
for ch_info in db_channels:
ch_key = ch_info.get('secret', ch_info.get('key', ''))
ci = ch_info.get('idx', ch_info.get('index'))
if ch_key and ci is not None:
channel_secrets[ci] = ch_key
if ch_idx in channel_secrets:
raw_text = None
raw_json_str = row.get('raw_json')
if raw_json_str:
try:
raw_text = json.loads(raw_json_str).get('text')
except (json.JSONDecodeError, TypeError):
pass
if not raw_text:
is_own = bool(row.get('is_own', 0))
if is_own:
device_name = runtime_config.get_device_name() or ''
raw_text = f"{device_name}: {row.get('content', '')}" if device_name else row.get('content', '')
else:
sender = row.get('sender', '')
raw_text = f"{sender}: {row.get('content', '')}" if sender else row.get('content', '')
pkt_payload = compute_pkt_payload(
channel_secrets[ch_idx], sender_ts, txt_type, raw_text
)
# Decode path_len
path_len_raw = row.get('path_len')
hop_count = None
path_hash_size = 1
if path_len_raw is not None:
hop_count, path_hash_size, _ = decode_path_len(path_len_raw)
meta = {
'success': True,
'snr': row.get('snr'),
'path_len': path_len_raw,
'hop_count': hop_count,
'path_hash_size': path_hash_size,
'pkt_payload': pkt_payload,
}
if pkt_payload:
meta['packet_hash'] = compute_packet_hash(pkt_payload)
echoes = db.get_echoes_for_message(pkt_payload)
if echoes:
meta['echo_count'] = len(echoes)
meta['echo_paths'] = [e.get('path', '') for e in echoes if e.get('path')]
meta['echo_snrs'] = [e.get('snr') for e in echoes if e.get('snr') is not None]
meta['echo_hash_sizes'] = [e.get('hash_size', 1) for e in echoes if e.get('path')]
return jsonify(meta)
pkt_payload = _get_row_pkt_payload(row, _build_channel_secrets(db))
echoes = db.get_echoes_for_message(pkt_payload) if pkt_payload else []
return jsonify(_build_message_meta(row, pkt_payload, echoes))
except Exception as e:
logger.error(f"Error fetching message meta: {e}")
@@ -864,12 +887,9 @@ def get_status():
message_count = 0
latest_timestamp = None
if db:
stats = db.get_stats()
message_count = stats.get('channel_messages', 0) + stats.get('direct_messages', 0)
# Get latest channel message timestamp
recent = db.get_channel_messages(limit=1)
if recent:
latest_timestamp = recent[0].get('timestamp')
summary = db.get_status_summary()
message_count = summary['message_count']
latest_timestamp = summary['latest_message_timestamp']
else:
message_count = parser.count_messages()
latest = parser.get_latest_message()
+160 -25
View File
@@ -391,6 +391,86 @@ function isContactProtectedByName(senderName) {
return pubkey && protectedContactPubkeys.has(pubkey.toLowerCase());
}
// =============================================================================
// Resync after a gap
//
// The chat view is push-driven: messages arrive over the socket and are
// appended one at a time. Whatever the socket misses - Android doze tearing the
// connection down behind a locked screen, a switch between Wi-Fi and mobile -
// is never drawn, and the list silently stops at the last message that got
// through. A browser tab hides this because it reloads the page on resume; the
// Android wrapper keeps the same page alive for days, so the gap stays until
// the app is force-stopped.
//
// So every way back from a gap ends up here: the socket reconnecting, the page
// becoming visible, the heartbeat noticing it was frozen, the Refresh menu
// item, and the wrapper's onResume hook.
// =============================================================================
let chatSocketEverConnected = false;
let resyncInFlight = false;
/** Re-read the message list and badges from the server. */
async function resyncFromServer(reason, { toast = false } = {}) {
if (resyncInFlight) return;
resyncInFlight = true;
console.log(`[resync] refreshing after ${reason}`);
try {
// The archive view is a frozen snapshot of one day - reloading it would
// fight the date the user picked. Its badges still need updating.
if (!currentArchiveDate) await loadMessages();
await checkForUpdates();
loadStatus();
updatePendingContactsBadge();
checkDmUpdates();
if (toast) showNotification('Messages refreshed', 'success');
} catch (error) {
console.error('[resync] failed:', error);
if (toast) showNotification('Refresh failed', 'danger');
} finally {
resyncInFlight = false;
}
}
/**
* Heartbeat that catches the cases no event announces.
*
* A tick arriving far later than scheduled means the page was frozen or
* throttled - precisely the window in which socket events go missing - so the
* gap is worth a resync even if the socket claims it never dropped. The same
* tick nudges a socket still stuck in its reconnect backoff.
*/
function startResyncHeartbeat() {
const TICK_MS = 20000;
let lastTickAt = Date.now();
setInterval(() => {
const now = Date.now();
const drift = now - lastTickAt;
lastTickAt = now;
if (document.hidden) return; // visibilitychange covers the way back
if (drift > TICK_MS * 3) {
resyncFromServer('timer gap');
return;
}
// connect() during backoff simply retries now instead of later; the
// 'connect' handler then does the resync
if (chatSocket && !chatSocket.connected) chatSocket.connect();
}, TICK_MS);
}
/**
* Called by the Android wrapper from onResume. The WebView is not guaranteed
* to fire visibilitychange for an Activity coming back to the foreground, so
* the wrapper says so itself.
*/
window.__mcAppResumed = function() {
if (chatSocket && !chatSocket.connected) chatSocket.connect();
resyncFromServer('app resumed');
};
// Initialize on page load
/**
* Connect to SocketIO /chat namespace for real-time message updates
@@ -402,9 +482,14 @@ function connectChatSocket() {
}
const wsUrl = window.location.origin;
// Default transports (polling, then upgrade to websocket). Long-polling was
// pinned in 1d47c9c because werkzeug had no websocket support; python-engineio
// 4.8.1 pulled in simple-websocket and it does now. Polling holds an HTTP
// connection open per tab, and browsers only allow six per origin, so three
// tabs starved every other request of a connection for tens of seconds.
// Upgrading moves that connection out of the HTTP pool. Where the upgrade is
// blocked (a proxy that drops Upgrade), the client stays on polling by itself.
chatSocket = io(wsUrl + '/chat', {
transports: ['polling'],
upgrade: false,
reconnection: true,
reconnectionDelay: 2000,
reconnectionDelayMax: 10000,
@@ -412,6 +497,15 @@ function connectChatSocket() {
chatSocket.on('connect', () => {
console.log('SocketIO connected to /chat');
// Everything pushed while the socket was down is gone for good - only a
// re-read of the list brings those messages back. Skipped on the very
// first connect, where DOMContentLoaded has just loaded them anyway.
if (chatSocketEverConnected) resyncFromServer('socket reconnect');
chatSocketEverConnected = true;
});
chatSocket.on('disconnect', (reason) => {
console.warn('SocketIO /chat disconnected:', reason);
});
chatSocket.on('connect_error', (err) => {
@@ -582,6 +676,8 @@ document.addEventListener('DOMContentLoaded', async function() {
// Connect SocketIO for real-time updates
connectChatSocket();
// Safety net for the updates the socket never delivered
startResyncHeartbeat();
console.log(`[init] UI ready in ${(performance.now() - initStart).toFixed(0)}ms`);
@@ -609,23 +705,34 @@ window.addEventListener('pageshow', function(event) {
});
// Handle app returning from background (PWA visibility change)
let hiddenSince = null;
document.addEventListener('visibilitychange', function() {
if (!document.hidden) {
// App became visible again, force viewport recalculation
console.log('App became visible, recalculating viewport');
setTimeout(() => {
window.scrollTo(0, 0);
window.dispatchEvent(new Event('resize'));
document.body.offsetHeight;
}, 100);
// Clear app badge when user returns to app
if ('clearAppBadge' in navigator) {
navigator.clearAppBadge().catch((error) => {
console.error('Error clearing app badge on visibility:', error);
});
}
if (document.hidden) {
hiddenSince = Date.now();
return;
}
// App became visible again, force viewport recalculation
console.log('App became visible, recalculating viewport');
setTimeout(() => {
window.scrollTo(0, 0);
window.dispatchEvent(new Event('resize'));
document.body.offsetHeight;
}, 100);
// Clear app badge when user returns to app
if ('clearAppBadge' in navigator) {
navigator.clearAppBadge().catch((error) => {
console.error('Error clearing app badge on visibility:', error);
});
}
// Anything longer than a glance away is long enough for the socket to have
// dropped messages, so catch up before the user reads a stale list
const away = hiddenSince ? Date.now() - hiddenSince : 0;
hiddenSince = null;
if (chatSocket && !chatSocket.connected) chatSocket.connect();
if (away > 10000) resyncFromServer('back from background');
});
/**
@@ -943,6 +1050,15 @@ function setupEventListeners() {
inst.hide();
});
// Manual refresh from the menu
const refreshBtn = document.getElementById('refreshBtn');
if (refreshBtn) {
refreshBtn.addEventListener('click', () => {
if (chatSocket && !chatSocket.connected) chatSocket.connect();
resyncFromServer('manual refresh', { toast: true });
});
}
// Notification toggle
const notificationsToggle = document.getElementById('notificationsToggle');
if (notificationsToggle) {
@@ -1107,9 +1223,17 @@ function appendMessageFromSocket(data) {
markChannelAsRead(currentChannelIdx, msg.timestamp);
}
// Cap on ids per /api/messages/meta request, to keep the query string short.
const META_BATCH_SIZE = 200;
/**
* Refresh metadata (SNR, hops, route, analyzer) for messages missing it.
* Fetches /api/messages/<id>/meta for each incomplete message, updates DOM in-place.
*
* Every echo triggers a sweep of the whole rendered list, and messages that
* never gain a route (no repeaters heard them) never stop qualifying so this
* must stay cheap. Ids are collected first and resolved with batched
* /api/messages/meta calls; one request per message used to flood the
* single-threaded server and hang the UI.
*/
async function refreshMessagesMeta(forceIds = []) {
const container = document.getElementById('messagesList');
@@ -1117,7 +1241,8 @@ async function refreshMessagesMeta(forceIds = []) {
const forced = new Set((forceIds || []).map(String));
// Find message wrappers that don't have full metadata yet
// Collect message wrappers that don't have full metadata yet
const pending = new Map(); // msgId -> wrapper
const wrappers = container.querySelectorAll('.message-wrapper[data-msg-id]');
for (const wrapper of wrappers) {
const msgId = wrapper.dataset.msgId;
@@ -1134,14 +1259,24 @@ async function refreshMessagesMeta(forceIds = []) {
if (hasRoute && hasAnalyzer) continue;
}
try {
const resp = await fetch(`/api/messages/${msgId}/meta`);
const meta = await resp.json();
if (!meta.success) continue;
pending.set(msgId, wrapper);
}
if (pending.size === 0) return;
updateMessageMetaDOM(wrapper, meta);
const ids = Array.from(pending.keys());
for (let i = 0; i < ids.length; i += META_BATCH_SIZE) {
const chunk = ids.slice(i, i + META_BATCH_SIZE);
try {
const resp = await fetch(`/api/messages/meta?ids=${chunk.join(',')}`);
const data = await resp.json();
if (!data.success || !data.metas) continue;
for (const [msgId, meta] of Object.entries(data.metas)) {
const wrapper = pending.get(msgId);
if (wrapper && meta.success) updateMessageMetaDOM(wrapper, meta);
}
} catch (e) {
console.error(`Error fetching meta for msg #${msgId}:`, e);
console.error('Error fetching message meta batch:', e);
}
}
}
+1 -2
View File
@@ -37,9 +37,8 @@ function connectWebSocket() {
console.log('Connecting to WebSocket:', wsUrl);
try {
// Default transports — see the note in app.js connectChatSocket().
socket = io(wsUrl + '/console', {
transports: ['polling'],
upgrade: false,
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 1000,
+51 -12
View File
@@ -114,6 +114,31 @@ function resolveConversationName(conversationId) {
return 'Unknown';
}
let chatSocketEverConnected = false;
let resyncInFlight = false;
/**
* Re-read the DM lists from the server.
*
* This view is push-driven, so a socket that drops while the phone sleeps
* leaves it showing whatever arrived last - the 60s poll eventually catches up,
* but only once its timer un-throttles. Every path back from a gap comes here.
*/
async function resyncFromServer(reason) {
if (resyncInFlight) return;
resyncInFlight = true;
console.log(`DM: [resync] refreshing after ${reason}`);
try {
await loadConversations();
if (currentConversationId) await loadMessages();
await loadStatus();
} catch (error) {
console.error('DM: [resync] failed:', error);
} finally {
resyncInFlight = false;
}
}
/**
* Connect to SocketIO /chat namespace for real-time DM and ACK updates
*/
@@ -124,9 +149,8 @@ function connectChatSocket() {
}
const wsUrl = window.location.origin;
// Default transports — see the note in app.js connectChatSocket().
chatSocket = io(wsUrl + '/chat', {
transports: ['polling'],
upgrade: false,
reconnection: true,
reconnectionDelay: 2000,
reconnectionDelayMax: 10000,
@@ -134,10 +158,14 @@ function connectChatSocket() {
chatSocket.on('connect', () => {
console.log('DM: SocketIO connected to /chat');
// Whatever arrived while the socket was down was never pushed to us;
// only re-reading the lists brings it back (see resyncFromServer)
if (chatSocketEverConnected) resyncFromServer('socket reconnect');
chatSocketEverConnected = true;
});
chatSocket.on('disconnect', () => {
console.log('DM: SocketIO disconnected');
chatSocket.on('disconnect', (reason) => {
console.log('DM: SocketIO disconnected:', reason);
});
// Real-time new DM message
@@ -330,16 +358,27 @@ window.addEventListener('pageshow', function(event) {
});
// Handle app returning from background (PWA visibility change)
let hiddenSince = null;
document.addEventListener('visibilitychange', function() {
if (!document.hidden) {
// App became visible again, force viewport recalculation
console.log('App became visible, recalculating viewport');
setTimeout(() => {
window.scrollTo(0, 0);
window.dispatchEvent(new Event('resize'));
document.body.offsetHeight;
}, 100);
if (document.hidden) {
hiddenSince = Date.now();
return;
}
// App became visible again, force viewport recalculation
console.log('App became visible, recalculating viewport');
setTimeout(() => {
window.scrollTo(0, 0);
window.dispatchEvent(new Event('resize'));
document.body.offsetHeight;
}, 100);
// Long enough away for the socket to have dropped updates - catch up
// rather than wait for the next 60s poll
const away = hiddenSince ? Date.now() - hiddenSince : 0;
hiddenSince = null;
if (chatSocket && !chatSocket.connected) chatSocket.connect();
if (away > 10000) resyncFromServer('back from background');
});
/**
+1 -2
View File
@@ -32,9 +32,8 @@
const LEVEL_ORDER = { DEBUG: 0, INFO: 1, WARNING: 2, ERROR: 3, CRITICAL: 4 };
// --- WebSocket ---
// Default transports — see the note in app.js connectChatSocket().
const socket = io('/logs', {
transports: ['polling'],
upgrade: false,
reconnection: true,
reconnectionDelay: 2000,
});
+10
View File
@@ -111,6 +111,16 @@
<div class="offcanvas-body">
<div class="list-group list-group-flush">
<!-- Messages -->
<!-- The Android wrapper has no pull-to-refresh of its own, so
this is the manual way out of a list that fell behind -->
<button id="refreshBtn" class="list-group-item list-group-item-action d-flex align-items-center gap-3" type="button"
data-bs-dismiss="offcanvas" title="Reload messages from the server">
<i class="bi bi-arrow-clockwise" style="font-size: 1.5rem;"></i>
<div class="flex-grow-1">
<div>Refresh</div>
<small class="d-block text-muted">Reload messages from server</small>
</div>
</button>
<button id="menu-filter" class="list-group-item list-group-item-action d-flex align-items-center gap-3 d-none" type="button">
<i class="bi bi-funnel" style="font-size: 1.5rem;"></i>
<div class="flex-grow-1">
+6 -2
View File
@@ -409,7 +409,11 @@ These are top-level routes (not under `/api/`), consumed by Docker's healthcheck
## WebSocket API
All Socket.IO clients (`/chat`, `/console`, `/logs`) are configured with `transports: ['polling']`. The Werkzeug dev server can't upgrade WebSockets, so every `io()` upgrade attempt previously returned HTTP 500 and clients fell into a polling/upgrade reconnect loop — visible as 1015 s freezes on app load. Long-polling keeps real-time pushes working with ~12 s latency.
All Socket.IO clients (`/chat`, `/console`, `/logs`) use the default transports: connect over long-polling, then upgrade to a real WebSocket. Where the upgrade is blocked (a reverse proxy that drops the `Upgrade` header) the client stays on polling by itself, so no configuration is needed either way.
From 2026-06-07 to 2026-07-31 the clients pinned `transports: ['polling'], upgrade: false`, because the Werkzeug server then had no WebSocket support and every `io()` upgrade attempt returned HTTP 500, producing a reconnect loop and 1015 s freezes on app load. That stopped being true when `python-engineio==4.8.1` was pinned (2026-07-14) and pulled in `simple-websocket`, which teaches Werkzeug to serve WebSockets.
**Do not re-pin polling.** Long-polling holds one HTTP connection open per tab for the life of the tab, and browsers allow only six concurrent HTTP/1.1 connections per origin *across all tabs*. Three open tabs therefore consumed the whole pool, and every other request — including ones the server answered in 10 ms — waited tens of seconds in the browser's queue for a free connection. Measured with three tabs open: `/health` took a **14.7 s median** from inside a tab while answering in **11 ms** to a client outside the browser at the same instant; after the upgrade the same probe reads 12 ms. A WebSocket is not part of that HTTP pool, so upgrading is what releases it.
### Console Namespace (`/console`)
@@ -444,7 +448,7 @@ Real-time log streaming via Socket.IO.
**Server → Client:**
- `log_line` - New log line
The `MemoryLogHandler` filters werkzeug access-log records for `/socket.io/` and `/api/logs/` paths before buffering/broadcasting. With `async_mode='threading'` Socket.IO falls back to long-polling; without this filter every poll is logged, the broadcast wakes the pending poll, the client re-polls immediately, and an open System Log tab spins at 10+ requests/sec.
The `MemoryLogHandler` filters werkzeug access-log records for `/socket.io/` and `/api/logs/` paths before buffering/broadcasting. Clients still open on long-polling before upgrading, and stay there wherever the upgrade is blocked; without this filter every poll is logged, the broadcast wakes the pending poll, the client re-polls immediately, and an open System Log tab spins at 10+ requests/sec.
---
+11 -1
View File
@@ -10,7 +10,17 @@ For deep technical notes, see [architecture.md](architecture.md). For the full g
## Unreleased
_Nothing yet since 2.4.0._
---
## 2.4.1 — 2026-07-31
### Fixes
- **You can keep mc-webui open in several tabs again.** Two or three open windows used to bring the whole thing to a crawl — the message list crept, buttons took ten or twenty seconds to do anything, and the status could sit on "Connecting…". It looked exactly like an overloaded server, and it wasn't: the server was answering in a few thousandths of a second the entire time. The live connection that pushes new messages to an open page was running in a mode that keeps a browser connection permanently occupied, and a browser only allows six connections to one address **shared across every tab**. Three tabs took the lot, so everything else — loading messages, marking them read, sending — queued in the browser waiting for a free one. That connection now uses a proper WebSocket, which does not come out of that budget. Measured with three tabs open: a request that had been taking around 15 seconds now takes about 12 milliseconds. The advice to keep only one window open no longer applies. If you run mc-webui behind a reverse proxy that isn't set up to pass WebSocket connections through, the page quietly falls back to the old behaviour and works exactly as before.
- **Messages no longer go missing after the app has been in the background.** Coming back to a minimised app — or to a phone that had been asleep — could show a chat that quietly stopped at whatever message arrived last before the screen went off, with everything since then missing until the app was force-stopped and reopened. New messages reach an open page over a live connection, and Android tears that connection down while the app sits in the background; nothing then went back to ask the server what had been missed. Now every way back from a gap re-reads the list: the connection coming back, the app returning to the foreground, and a heartbeat that notices when the page has been frozen. The same applies to direct messages, and to a browser tab that lost its network for a while.
- **A Refresh item in the menu.** The browser's pull-to-refresh has no equivalent in the Android app, so there is now a **Refresh** entry at the top of the menu that reloads the messages from the server on demand — in the app, and everywhere else too.
- **The connection-status check is about five times cheaper.** Every open page asks the server how the mesh device is doing — on load, once a minute after that, and each time you come back to a tab you had left. Answering that took roughly a quarter of a second, almost none of it spent on the device itself: the server was counting the rows of every table in the database to report two numbers, and the row it needed for the "last message" timestamp was found by reading the entire message table and sorting it. The heaviest part, counting the radio-echo records, was thrown away unused — and that table only ever grows, so the check was getting slower the longer an instance had been running. It now asks for exactly the three values it needs, in one go. This is a smaller effect than the bundling below and it does not change what you see on screen; it does take a recurring cost off the server on every open page.
- **Far less load on the server while you have messages on screen.** Whenever new radio traffic came in, the page asked the server about every message on screen separately — hundreds of individual requests at a time, repeated every few seconds, and the same messages over and over. On a busy channel that was thousands of requests a minute from a single tab, which left the server little room to answer anything else; the worst of it looked like the mesh device had dropped off, with the message list stuck on "Loading messages…" and the status on "Connecting…", while the device was connected the whole time. Those requests are now bundled into one. A page that needed hundreds of requests per update now needs a single one. The remaining reason several windows were slow is fixed separately — see the first entry above.
---
+3
View File
@@ -32,6 +32,9 @@ flask-socketio==5.3.6
# Observer: MQTT packet publishing (meshcore-packet-capture compatible)
paho-mqtt==2.1.0
python-socketio==5.10.0
# Pulls in simple-websocket, which is what lets the werkzeug server serve real
# WebSockets. Clients rely on that upgrade; without it they fall back to
# long-polling and starve the browser's per-origin connection pool.
python-engineio==4.8.1
# v2: Direct MeshCore device communication (replaces bridge subprocess)