diff --git a/app/device_manager.py b/app/device_manager.py index 9496945..48a2653 100644 --- a/app/device_manager.py +++ b/app/device_manager.py @@ -694,6 +694,7 @@ class DeviceManager: 'attempt': ctx['attempt'], 'max_attempts': ctx['max_attempts'], 'path': ctx['path'], + 'hash_size': ctx.get('hash_size', 1), }, namespace='/chat') # If path is empty (FLOOD delivery), schedule delayed read from device if not ctx['path']: @@ -873,6 +874,11 @@ class DeviceManager: # Store delivery info — use path from PATH event (actual discovered route) ctx = self._retry_context.pop(dm_id, None) discovered_path = data.get('path', '') + # Derive hash_size from PATH event's path_len, fallback to ctx + path_len_val = data.get('path_len') + disc_hash_size = ctx.get('hash_size', 1) if ctx else 1 + if path_len_val is not None and path_len_val != 0xFF: + disc_hash_size = (path_len_val >> 6) + 1 if ctx: self.db.update_dm_delivery_info( dm_id, ctx['attempt'], ctx['max_attempts'], discovered_path) @@ -882,6 +888,7 @@ class DeviceManager: 'attempt': ctx['attempt'], 'max_attempts': ctx['max_attempts'], 'path': discovered_path, + 'hash_size': disc_hash_size, }, namespace='/chat') # If path still empty, schedule delayed read from device contacts if not discovered_path and pubkey: @@ -901,6 +908,11 @@ class DeviceManager: # Update delivery_path for recently-delivered DMs where _on_ack # stored empty path (FLOOD mode) before PATH_UPDATE could provide it discovered_path = data.get('path', '') + # Derive hash_size from PATH event's path_len + backfill_hash_size = 1 + backfill_path_len = data.get('path_len') + if backfill_path_len is not None and backfill_path_len != 0xFF: + backfill_hash_size = (backfill_path_len >> 6) + 1 if pubkey: if discovered_path: recent = self.db.get_recent_delivered_dm_with_empty_path(pubkey) @@ -914,6 +926,7 @@ class DeviceManager: 'attempt': recent['delivery_attempt'], 'max_attempts': recent['delivery_max_attempts'], 'path': discovered_path, + 'hash_size': backfill_hash_size, }, namespace='/chat') logger.debug(f"Updated delivery path for dm_id={recent['id']} " f"with discovered path {discovered_path[:16]}") @@ -1435,6 +1448,7 @@ class DeviceManager: out_path = contact.get('out_path', '') out_path_len = contact.get('out_path_len', -1) path_hex = self._extract_path_hex(out_path, out_path_len) + bf_hash_size = ((out_path_len >> 6) + 1) if out_path_len > 0 else 1 if not path_hex: logger.debug(f"Delayed path backfill: still no path for dm_id={dm_id}") return @@ -1453,6 +1467,7 @@ class DeviceManager: 'attempt': dm.get('delivery_attempt') or 1, 'max_attempts': dm.get('delivery_max_attempts') or 1, 'path': path_hex, + 'hash_size': bf_hash_size, }, namespace='/chat') logger.info(f"Delayed path backfill: updated dm_id={dm_id} with path {path_hex[:16]}") except asyncio.CancelledError: @@ -1548,8 +1563,9 @@ class DeviceManager: if not no_auto_flood: max_attempts += cfg['flood_max_retries'] - # Track current path hex for delivery info (actual route, not label) + # Track current path hex and hash_size for delivery info path_desc = self._extract_path_hex(original_out_path, original_out_path_len) if has_path else '' + path_hash_size = ((original_out_path_len >> 6) + 1) if has_path and original_out_path_len > 0 else 1 logger.info(f"DM retry task started: dm_id={dm_id}, scenario={scenario}, " f"configured_paths={len(configured_paths)}, no_auto_flood={no_auto_flood}, " @@ -1560,7 +1576,8 @@ class DeviceManager: async def _retry(attempt_num, min_wait_s): display = attempt_num + 1 # attempt 0 = initial send = display 1 self._retry_context[dm_id] = { - 'attempt': display, 'max_attempts': max_attempts, 'path': path_desc, + 'attempt': display, 'max_attempts': max_attempts, + 'path': path_desc, 'hash_size': path_hash_size, } self._emit_retry_status(dm_id, initial_ack, display, max_attempts) return await self._dm_retry_send_and_wait( @@ -1571,7 +1588,8 @@ class DeviceManager: # ── Wait for initial ACK (attempt 1) ── # Delivery info stored by _on_ack() via _retry_context (avoids cancel race) self._retry_context[dm_id] = { - 'attempt': 1, 'max_attempts': max_attempts, 'path': path_desc, + 'attempt': 1, 'max_attempts': max_attempts, + 'path': path_desc, 'hash_size': path_hash_size, } self._emit_retry_status(dm_id, initial_ack, 1, max_attempts) if initial_ack: @@ -1615,6 +1633,7 @@ class DeviceManager: except Exception: pass path_desc = '' + path_hash_size = 1 for _ in range(cfg['direct_flood_retries']): attempt += 1 if await _retry(attempt, float(cfg['flood_interval'])): @@ -1640,6 +1659,7 @@ class DeviceManager: await self._change_path_async(contact, path_info['path_hex'], path_info['hash_size']) label = path_info.get('label', '') path_desc = path_info['path_hex'] + path_hash_size = path_info['hash_size'] logger.info(f"DM retry: switched to path '{label}' ({path_info['path_hex']})") except Exception as e: logger.warning(f"DM retry: failed to switch path: {e}") @@ -1679,6 +1699,7 @@ class DeviceManager: await self._change_path_async(contact, path_info['path_hex'], path_info['hash_size']) label = path_info.get('label', '') path_desc = path_info['path_hex'] + path_hash_size = path_info['hash_size'] logger.info(f"DM retry: switched to path '{label}' ({path_info['path_hex']})") except Exception as e: logger.warning(f"DM retry: failed to switch path: {e}") @@ -1698,6 +1719,7 @@ class DeviceManager: except Exception: pass path_desc = '' + path_hash_size = 1 for _ in range(cfg['flood_max_retries']): attempt += 1 if await _retry(attempt, float(cfg['flood_interval'])): diff --git a/app/static/js/dm.js b/app/static/js/dm.js index 84e7bc8..765db85 100644 --- a/app/static/js/dm.js +++ b/app/static/js/dm.js @@ -162,7 +162,7 @@ function connectChatSocket() { // Build delivery meta text const parts = []; if (data.attempt && data.max_attempts) parts.push(`Attempt ${data.attempt}/${data.max_attempts}`); - const hexRoute = formatDmRoute(data.path); + const hexRoute = formatDmRoute(data.path, data.hash_size); if (hexRoute) parts.push(`Route: ${hexRoute}`); if (parts.length > 0) { let metaEl = msgDiv.querySelector('.dm-delivery-meta'); @@ -1158,7 +1158,7 @@ function displayMessages(messages) { if (msg.delivery_attempt && msg.delivery_max_attempts) { title += ` (${msg.delivery_attempt}/${msg.delivery_max_attempts})`; } - const route = formatDmRoute(msg.delivery_path); + const route = formatDmRoute(msg.delivery_path, msg.path_hash_size); if (route) title += `, Route: ${route}`; else if (msg.delivery_route) title += `, ${msg.delivery_route.replace('PATH_', '')}`; if (msg.delivery_snr !== null && msg.delivery_snr !== undefined) { @@ -1198,7 +1198,7 @@ function displayMessages(messages) { } // Show route only for delivered messages (not failed) if (msg.status === 'delivered') { - const routeHtml = buildDmRouteHtml(msg.delivery_path); + const routeHtml = buildDmRouteHtml(msg.delivery_path, msg.path_hash_size); if (routeHtml) { parts.push(routeHtml); } else if (msg.delivery_route) { @@ -1384,12 +1384,27 @@ function resendMessage(content) { } /** - * Format a hex path as route string (e.g. "5e34e761" → "5e→34→e7→61") + * Segment a hex path into hop-sized chunks based on hash_size. + * @param {string} hexPath - raw hex string (e.g. "5e0558d1") + * @param {number} hashSize - bytes per hop hash (1, 2, or 3) + * @returns {string[]} array of uppercase hex segments + */ +function segmentHexPath(hexPath, hashSize) { + if (!hexPath || !/^[0-9a-f]+$/i.test(hexPath)) return []; + const chunkLen = (hashSize || 1) * 2; + const segments = []; + for (let i = 0; i < hexPath.length; i += chunkLen) { + segments.push(hexPath.substring(i, i + chunkLen).toUpperCase()); + } + return segments; +} + +/** + * Format a hex path as route string (e.g. "5e34e761" → "5E→34→E7→61") * Truncates if more than 4 segments. Returns '' for non-hex strings. */ -function formatDmRoute(hexPath) { - if (!hexPath || !/^[0-9a-f]+$/i.test(hexPath)) return ''; - const segments = hexPath.match(/.{1,2}/g) || []; +function formatDmRoute(hexPath, hashSize) { + const segments = segmentHexPath(hexPath, hashSize || 1); if (segments.length === 0) return ''; if (segments.length > 4) { return `${segments[0]}\u2192...\u2192${segments[segments.length - 1]}`; @@ -1401,26 +1416,26 @@ function formatDmRoute(hexPath) { * Build a clickable route span for DM delivery meta. * Short routes are plain text; long routes (>4 hops) are clickable to show full path. */ -function buildDmRouteHtml(hexPath) { - if (!hexPath || !/^[0-9a-f]+$/i.test(hexPath)) return ''; - const segments = hexPath.match(/.{1,2}/g) || []; +function buildDmRouteHtml(hexPath, hashSize) { + const segments = segmentHexPath(hexPath, hashSize || 1); if (segments.length === 0) return ''; const short = segments.length > 4 ? `${segments[0]}\u2192...\u2192${segments[segments.length - 1]}` : segments.join('\u2192'); if (segments.length <= 4) return `Route: ${short}`; + const hs = hashSize || 1; const escaped = hexPath.replace(/'/g, "\\'"); - return `Route: ${short}`; + return `Route: ${short}`; } /** * Show full route popup for DM delivery path (same style as channel path popup) */ -function showDmRoutePopup(element, hexPath) { +function showDmRoutePopup(element, hexPath, hashSize) { const existing = document.querySelector('.path-popup'); if (existing) existing.remove(); - const segments = hexPath.match(/.{1,2}/g) || []; + const segments = segmentHexPath(hexPath, hashSize || 1); const fullRoute = segments.join(' \u2192 '); const popup = document.createElement('div');