From f0f06671cc835748da7530df49ba8ea054cb4c2b Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Tue, 31 Mar 2026 12:28:47 -0700 Subject: [PATCH 01/17] Make new message button clearer --- frontend/src/components/Sidebar.tsx | 54 ++++++++++++++++------------- frontend/src/test/sidebar.test.tsx | 40 +++++++++++++++++++++ 2 files changed, 69 insertions(+), 25 deletions(-) diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 088031b..0ae9bf1 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -853,41 +853,45 @@ export function Sidebar({ aria-label="Conversations" > {/* Header */} -
-
- setSearchQuery(e.target.value)} - className={cn('h-7 text-[13px] bg-background/50', searchQuery ? 'pr-8' : 'pr-3')} - /> - {searchQuery && ( - - )} -
+
{/* List */}
+
+
+ setSearchQuery(e.target.value)} + className={cn('h-7 text-[13px] bg-background/50', searchQuery ? 'pr-8' : 'pr-3')} + /> + {searchQuery && ( + + )} +
+
+ {/* Tools */} {toolRows.length > 0 && ( <> diff --git a/frontend/src/test/sidebar.test.tsx b/frontend/src/test/sidebar.test.tsx index 167ab1d..a2a75ab 100644 --- a/frontend/src/test/sidebar.test.tsx +++ b/frontend/src/test/sidebar.test.tsx @@ -122,6 +122,46 @@ describe('Sidebar section summaries', () => { expect(within(getSectionHeaderContainer('Repeaters')).getByText('4')).toBeInTheDocument(); }); + it('renders a full add channel/contact button above search and calls onNewMessage', () => { + const onNewMessage = vi.fn(); + + render( + + ); + + const addButton = screen.getByRole('button', { name: 'Add channel or contact' }); + const search = screen.getByLabelText('Search conversations'); + const nav = screen.getByRole('navigation', { name: 'Conversations' }); + const toolsButton = screen.getByRole('button', { name: 'Tools' }); + + expect(addButton).toHaveTextContent('Add Channel/Contact'); + expect( + addButton.compareDocumentPosition(search) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + expect(nav.compareDocumentPosition(search) & Node.DOCUMENT_POSITION_CONTAINED_BY).toBeTruthy(); + expect( + search.compareDocumentPosition(toolsButton) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + + fireEvent.click(addButton); + expect(onNewMessage).toHaveBeenCalledTimes(1); + }); + it('turns favorites and channels rollups red when they contain a mention', () => { renderSidebar({ mentions: { From 29e9a5f70162997d622dd999de8dfe478ab07487 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Tue, 31 Mar 2026 12:35:35 -0700 Subject: [PATCH 02/17] Be more resilient in noise floor gathering --- app/services/radio_noise_floor.py | 13 +++++++++-- tests/test_radio_noise_floor.py | 37 +++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 tests/test_radio_noise_floor.py diff --git a/app/services/radio_noise_floor.py b/app/services/radio_noise_floor.py index d928821..beb9ce9 100644 --- a/app/services/radio_noise_floor.py +++ b/app/services/radio_noise_floor.py @@ -60,8 +60,17 @@ async def sample_noise_floor_once(*, blocking: bool = False) -> None: async def _noise_floor_sampling_loop() -> None: while True: - await sample_noise_floor_once() - await asyncio.sleep(NOISE_FLOOR_SAMPLE_INTERVAL_SECONDS) + try: + await sample_noise_floor_once() + except asyncio.CancelledError: + raise + except Exception: + logger.exception("Noise floor sampling loop crashed during sample") + + try: + await asyncio.sleep(NOISE_FLOOR_SAMPLE_INTERVAL_SECONDS) + except asyncio.CancelledError: + raise async def start_noise_floor_sampling() -> None: diff --git a/tests/test_radio_noise_floor.py b/tests/test_radio_noise_floor.py new file mode 100644 index 0000000..33d1c2b --- /dev/null +++ b/tests/test_radio_noise_floor.py @@ -0,0 +1,37 @@ +import asyncio +from unittest.mock import patch + +import pytest + +from app.services import radio_noise_floor + + +class TestNoiseFloorSamplingLoop: + @pytest.mark.asyncio + async def test_logs_and_continues_after_unexpected_sample_exception(self): + sample_calls = 0 + sleep_calls = 0 + + async def fake_sample() -> None: + nonlocal sample_calls + sample_calls += 1 + if sample_calls == 1: + raise RuntimeError("boom") + + async def fake_sleep(_seconds: int) -> None: + nonlocal sleep_calls + sleep_calls += 1 + if sleep_calls >= 2: + raise asyncio.CancelledError() + + with ( + patch.object(radio_noise_floor, "sample_noise_floor_once", side_effect=fake_sample), + patch.object(radio_noise_floor.asyncio, "sleep", side_effect=fake_sleep), + patch.object(radio_noise_floor.logger, "exception") as mock_exception, + ): + with pytest.raises(asyncio.CancelledError): + await radio_noise_floor._noise_floor_sampling_loop() + + assert sample_calls == 2 + assert sleep_calls == 2 + mock_exception.assert_called_once() From 9f4737d350ab48c78bc4fb23a2ffbf5a5bb74779 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Tue, 31 Mar 2026 12:55:52 -0700 Subject: [PATCH 03/17] Add hashtag link detection. Closes #134. --- frontend/src/App.tsx | 42 +++++++- frontend/src/components/ConversationPane.tsx | 3 + frontend/src/components/MessageList.tsx | 106 +++++++++++++++++-- frontend/src/components/NewMessageModal.tsx | 26 ++++- frontend/src/test/messageList.test.tsx | 53 ++++++++++ frontend/src/test/messageParser.test.ts | 35 +++++- frontend/src/test/newMessageModal.test.tsx | 26 ++++- frontend/src/utils/messageParser.ts | 32 ++++++ 8 files changed, 308 insertions(+), 15 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6d50afb..47b5f09 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -31,6 +31,12 @@ interface ChannelUnreadMarker { lastReadAt: number | null; } +interface NewMessagePrefillRequest { + tab: 'hashtag'; + hashtagName: string; + nonce: number; +} + interface UnreadBoundaryBackfillParams { activeConversation: Conversation | null; unreadMarker: ChannelUnreadMarker | null; @@ -77,6 +83,8 @@ export function App() { const messageInputRef = useRef(null); const [rawPackets, setRawPackets] = useState([]); const [channelUnreadMarker, setChannelUnreadMarker] = useState(null); + const [newMessagePrefillRequest, setNewMessagePrefillRequest] = + useState(null); const [visibilityVersion, setVisibilityVersion] = useState(0); const lastUnreadBackfillAttemptRef = useRef(null); const { @@ -103,8 +111,8 @@ export function App() { setDistanceUnit, handleCloseSettingsView, handleToggleSettingsView, - handleOpenNewMessage, - handleCloseNewMessage, + handleOpenNewMessage: openNewMessageModal, + handleCloseNewMessage: closeNewMessageModal, handleToggleCracker, } = useAppShell(); @@ -413,6 +421,34 @@ export function App() { [fetchUndecryptedCount, setChannels] ); + const handleOpenNewMessage = useCallback(() => { + setNewMessagePrefillRequest(null); + openNewMessageModal(); + }, [openNewMessageModal]); + + const handleCloseNewMessage = useCallback(() => { + setNewMessagePrefillRequest(null); + closeNewMessageModal(); + }, [closeNewMessageModal]); + + const handleChannelReferenceClick = useCallback( + (channelName: string) => { + const existingChannel = channels.find((channel) => channel.name === channelName); + if (existingChannel) { + handleNavigateToChannel(existingChannel.key); + return; + } + + setNewMessagePrefillRequest((previous) => ({ + tab: 'hashtag', + hashtagName: channelName.slice(1), + nonce: (previous?.nonce ?? 0) + 1, + })); + openNewMessageModal(); + }, + [channels, handleNavigateToChannel, openNewMessageModal] + ); + const statusProps = { health, config, @@ -468,6 +504,7 @@ export function App() { onOpenContactInfo: handleOpenContactInfo, onOpenChannelInfo: handleOpenChannelInfo, onSenderClick: handleSenderClick, + onChannelReferenceClick: handleChannelReferenceClick, onLoadOlder: fetchOlderMessages, onResendChannelMessage: handleResendChannelMessage, onTargetReached: () => setTargetMessageId(null), @@ -526,6 +563,7 @@ export function App() { }; const newMessageModalProps = { undecryptedCount, + prefillRequest: newMessagePrefillRequest, onCreateContact: handleCreateContact, onCreateChannel: handleCreateChannel, onCreateHashtagChannel: handleCreateHashtagChannel, diff --git a/frontend/src/components/ConversationPane.tsx b/frontend/src/components/ConversationPane.tsx index aa97aeb..ca7ea9a 100644 --- a/frontend/src/components/ConversationPane.tsx +++ b/frontend/src/components/ConversationPane.tsx @@ -65,6 +65,7 @@ interface ConversationPaneProps { onOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void; onOpenChannelInfo: (channelKey: string) => void; onSenderClick: (sender: string) => void; + onChannelReferenceClick?: (channelName: string) => void; onLoadOlder: () => Promise; onResendChannelMessage: (messageId: number, newTimestamp?: boolean) => Promise; onTargetReached: () => void; @@ -131,6 +132,7 @@ export function ConversationPane({ onOpenContactInfo, onOpenChannelInfo, onSenderClick, + onChannelReferenceClick, onLoadOlder, onResendChannelMessage, onTargetReached, @@ -284,6 +286,7 @@ export function ConversationPane({ activeConversation.type === 'channel' ? onDismissUnreadMarker : undefined } onSenderClick={activeConversation.type === 'channel' ? onSenderClick : undefined} + onChannelReferenceClick={onChannelReferenceClick} onLoadOlder={onLoadOlder} onResendChannelMessage={ activeConversation.type === 'channel' ? onResendChannelMessage : undefined diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx index ea39063..af13a9a 100644 --- a/frontend/src/components/MessageList.tsx +++ b/frontend/src/components/MessageList.tsx @@ -11,7 +11,11 @@ import { import type { Channel, Contact, Message, MessagePath, RadioConfig, RawPacket } from '../types'; import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types'; import { api } from '../api'; -import { formatTime, parseSenderFromText } from '../utils/messageParser'; +import { + findLinkedChannelReferences, + formatTime, + parseSenderFromText, +} from '../utils/messageParser'; import { formatHopCounts, type SenderInfo } from '../utils/pathUtils'; import { getDirectContactRoute } from '../utils/pathUtils'; import { ContactAvatar } from './ContactAvatar'; @@ -33,6 +37,7 @@ interface MessageListProps { onSenderClick?: (sender: string) => void; onLoadOlder?: () => void; onResendChannelMessage?: (messageId: number, newTimestamp?: boolean) => void; + onChannelReferenceClick?: (channelName: string) => void; radioName?: string; config?: RadioConfig | null; onOpenContactInfo?: (publicKey: string, fromChannel?: boolean) => void; @@ -48,8 +53,64 @@ interface MessageListProps { const URL_PATTERN = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/g; -// Helper to convert URLs in a plain text string into clickable links -function linkifyText(text: string, keyPrefix: string): ReactNode[] { +function renderChannelReferences( + text: string, + keyPrefix: string, + onChannelReferenceClick?: (channelName: string) => void +): ReactNode[] { + const references = findLinkedChannelReferences(text); + if (references.length === 0) { + return [text]; + } + + const parts: ReactNode[] = []; + let lastIndex = 0; + + references.forEach((reference, index) => { + if (reference.start > lastIndex) { + parts.push(text.slice(lastIndex, reference.start)); + } + + const className = + 'rounded px-0.5 font-medium text-primary underline underline-offset-2 transition-colors'; + if (onChannelReferenceClick) { + parts.push( + + ); + } else { + parts.push( + + {reference.label} + + ); + } + + lastIndex = reference.end; + }); + + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + + return parts; +} + +// Helper to convert URLs and channel references in a plain text string into rich content +function linkifyText( + text: string, + keyPrefix: string, + onChannelReferenceClick?: (channelName: string) => void +): ReactNode[] { const parts: ReactNode[] = []; let lastIndex = 0; let match: RegExpExecArray | null; @@ -58,7 +119,13 @@ function linkifyText(text: string, keyPrefix: string): ReactNode[] { URL_PATTERN.lastIndex = 0; while ((match = URL_PATTERN.exec(text)) !== null) { if (match.index > lastIndex) { - parts.push(text.slice(lastIndex, match.index)); + parts.push( + ...renderChannelReferences( + text.slice(lastIndex, match.index), + `${keyPrefix}-text-${keyIndex}`, + onChannelReferenceClick + ) + ); } parts.push( void +): ReactNode { const mentionPattern = /@\[([^\]]+)\]/g; const parts: ReactNode[] = []; let lastIndex = 0; @@ -92,7 +171,13 @@ function renderTextWithMentions(text: string, radioName?: string): ReactNode { while ((match = mentionPattern.exec(text)) !== null) { // Add text before the match (with linkification) if (match.index > lastIndex) { - parts.push(...linkifyText(text.slice(lastIndex, match.index), `pre-${keyIndex}`)); + parts.push( + ...linkifyText( + text.slice(lastIndex, match.index), + `pre-${keyIndex}`, + onChannelReferenceClick + ) + ); } const mentionedName = match[1]; @@ -115,7 +200,7 @@ function renderTextWithMentions(text: string, radioName?: string): ReactNode { // Add remaining text after last match (with linkification) if (lastIndex < text.length) { - parts.push(...linkifyText(text.slice(lastIndex), `post-${keyIndex}`)); + parts.push(...linkifyText(text.slice(lastIndex), `post-${keyIndex}`, onChannelReferenceClick)); } return parts.length > 0 ? parts : text; @@ -188,6 +273,7 @@ export function MessageList({ onSenderClick, onLoadOlder, onResendChannelMessage, + onChannelReferenceClick, radioName, config, onOpenContactInfo, @@ -911,7 +997,7 @@ export function MessageList({
{content.split('\n').map((line, i, arr) => ( - {renderTextWithMentions(line, radioName)} + {renderTextWithMentions(line, radioName, onChannelReferenceClick)} {i < arr.length - 1 &&
}
))} diff --git a/frontend/src/components/NewMessageModal.tsx b/frontend/src/components/NewMessageModal.tsx index bb2d91a..7cb8885 100644 --- a/frontend/src/components/NewMessageModal.tsx +++ b/frontend/src/components/NewMessageModal.tsx @@ -1,4 +1,4 @@ -import { useState, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Dice5 } from 'lucide-react'; import { Dialog, @@ -20,6 +20,11 @@ type Tab = 'new-contact' | 'new-channel' | 'hashtag'; interface NewMessageModalProps { open: boolean; undecryptedCount: number; + prefillRequest?: { + tab: 'hashtag'; + hashtagName: string; + nonce: number; + } | null; onClose: () => void; onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise; onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise; @@ -29,6 +34,7 @@ interface NewMessageModalProps { export function NewMessageModal({ open, undecryptedCount, + prefillRequest = null, onClose, onCreateContact, onCreateChannel, @@ -53,6 +59,24 @@ export function NewMessageModal({ setError(''); }; + useEffect(() => { + if (!open || !prefillRequest) { + return; + } + + setTab(prefillRequest.tab); + setName(prefillRequest.hashtagName); + setContactKey(''); + setChannelKey(''); + setTryHistorical(false); + setPermitCapitals(false); + setError(''); + setLoading(false); + requestAnimationFrame(() => { + hashtagInputRef.current?.focus(); + }); + }, [open, prefillRequest]); + const handleCreate = async () => { setError(''); setLoading(true); diff --git a/frontend/src/test/messageList.test.tsx b/frontend/src/test/messageList.test.tsx index a691a91..ce68d79 100644 --- a/frontend/src/test/messageList.test.tsx +++ b/frontend/src/test/messageList.test.tsx @@ -140,6 +140,59 @@ describe('MessageList channel sender rendering', () => { expect(screen.getByRole('button', { name: 'View info for Alice' })).toBeInTheDocument(); }); + it('renders valid channel references as clickable links and ignores invalid ones', async () => { + const user = userEvent.setup(); + const onChannelReferenceClick = vi.fn(); + + render( + + ); + + const linkedChannel = screen.getByRole('button', { name: '#mesh-room' }); + expect(linkedChannel).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '#bad--room' })).not.toBeInTheDocument(); + expect( + screen.getByRole('link', { name: 'https://example.com/#also-skip' }) + ).toBeInTheDocument(); + + await user.click(linkedChannel); + + expect(onChannelReferenceClick).toHaveBeenCalledWith('#mesh-room'); + }); + + it('links valid channel references in direct messages too', async () => { + const user = userEvent.setup(); + const onChannelReferenceClick = vi.fn(); + + render( + + ); + + await user.click(screen.getByRole('button', { name: '#ops-room' })); + + expect(onChannelReferenceClick).toHaveBeenCalledWith('#ops-room'); + }); + it('renders and dismisses an unread marker at the first unread message boundary', async () => { const user = userEvent.setup(); const messages = [ diff --git a/frontend/src/test/messageParser.test.ts b/frontend/src/test/messageParser.test.ts index cde9690..4304810 100644 --- a/frontend/src/test/messageParser.test.ts +++ b/frontend/src/test/messageParser.test.ts @@ -6,7 +6,12 @@ */ import { describe, it, expect } from 'vitest'; -import { parseSenderFromText, formatTime } from '../utils/messageParser'; +import { + findLinkedChannelReferences, + formatTime, + isValidLinkedChannelName, + parseSenderFromText, +} from '../utils/messageParser'; describe('parseSenderFromText', () => { it('extracts sender and content from "sender: message" format', () => { @@ -95,3 +100,31 @@ describe('formatTime', () => { expect(result).toMatch(/\d{1,2}:\d{2}/); // time portion }); }); + +describe('linked channel references', () => { + it('accepts lowercase alphanumeric names with single dashes', () => { + expect(isValidLinkedChannelName('ops')).toBe(true); + expect(isValidLinkedChannelName('ops-1')).toBe(true); + expect(isValidLinkedChannelName('1-2-3')).toBe(true); + }); + + it('rejects uppercase, leading or trailing dashes, and repeated dashes', () => { + expect(isValidLinkedChannelName('Ops')).toBe(false); + expect(isValidLinkedChannelName('-ops')).toBe(false); + expect(isValidLinkedChannelName('ops-')).toBe(false); + expect(isValidLinkedChannelName('ops--room')).toBe(false); + }); + + it('finds standalone linked channel references in message text', () => { + expect(findLinkedChannelReferences('Join #mesh-room then say hi in #ops2')).toEqual([ + { label: '#mesh-room', start: 5, end: 15 }, + { label: '#ops2', start: 31, end: 36 }, + ]); + }); + + it('ignores invalid or embedded channel-like text', () => { + expect( + findLinkedChannelReferences('skip #Bad #bad--name abc#ops #ops- #opsRoom #ops_room #good-room,') + ).toEqual([]); + }); +}); diff --git a/frontend/src/test/newMessageModal.test.tsx b/frontend/src/test/newMessageModal.test.tsx index d8c8d2e..6bfa287 100644 --- a/frontend/src/test/newMessageModal.test.tsx +++ b/frontend/src/test/newMessageModal.test.tsx @@ -32,7 +32,10 @@ describe('NewMessageModal form reset', () => { vi.clearAllMocks(); }); - function renderModal(open = true) { + function renderModal( + open = true, + overrides: Partial[0]> = {} + ) { return render( { onCreateContact={onCreateContact} onCreateChannel={onCreateChannel} onCreateHashtagChannel={onCreateHashtagChannel} + {...overrides} /> ); } @@ -50,6 +54,26 @@ describe('NewMessageModal form reset', () => { } describe('hashtag tab', () => { + it('prefills the hashtag tab from a linked channel request', async () => { + renderModal(true, { + prefillRequest: { + tab: 'hashtag', + hashtagName: 'mesh-room', + nonce: 1, + }, + }); + + await waitFor(() => { + expect(screen.getByRole('tab', { name: 'Hashtag Channel' })).toHaveAttribute( + 'data-state', + 'active' + ); + }); + expect((screen.getByPlaceholderText('channel-name') as HTMLInputElement).value).toBe( + 'mesh-room' + ); + }); + it('clears name after successful Create', async () => { const user = userEvent.setup(); const { unmount } = renderModal(); diff --git a/frontend/src/utils/messageParser.ts b/frontend/src/utils/messageParser.ts index 9cfe2d1..5fb864a 100644 --- a/frontend/src/utils/messageParser.ts +++ b/frontend/src/utils/messageParser.ts @@ -2,6 +2,9 @@ * Parse sender from channel message text. * Channel messages have format "sender: message". */ +const HASHTAG_CHANNEL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const HASHTAG_CHANNEL_REFERENCE_PATTERN = /(^|\s)(#[a-z0-9]+(?:-[a-z0-9]+)*)(?=$|\s)/g; + export function parseSenderFromText(text: string): { sender: string | null; content: string } { const colonIndex = text.indexOf(': '); if (colonIndex > 0 && colonIndex < 50) { @@ -17,6 +20,35 @@ export function parseSenderFromText(text: string): { sender: string | null; cont return { sender: null, content: text }; } +export interface HashtagChannelReference { + label: string; + start: number; + end: number; +} + +export function isValidLinkedChannelName(name: string): boolean { + return HASHTAG_CHANNEL_NAME_PATTERN.test(name); +} + +export function findLinkedChannelReferences(text: string): HashtagChannelReference[] { + const references: HashtagChannelReference[] = []; + let match: RegExpExecArray | null; + + HASHTAG_CHANNEL_REFERENCE_PATTERN.lastIndex = 0; + while ((match = HASHTAG_CHANNEL_REFERENCE_PATTERN.exec(text)) !== null) { + const prefix = match[1]; + const label = match[2]; + const start = match.index + prefix.length; + references.push({ + label, + start, + end: start + label.length, + }); + } + + return references; +} + /** * Format a Unix timestamp to a time string. * Shows date for messages not from today. From c83f9b00055ae9164d90b3106050a52ea5b8e529 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Tue, 31 Mar 2026 13:10:02 -0700 Subject: [PATCH 04/17] Rename Best RSSI to Strongest Neighbor. Closes #136. --- frontend/src/components/RawPacketFeedView.tsx | 32 +++++++++---------- frontend/src/test/messageParser.test.ts | 4 ++- frontend/src/test/rawPacketFeedView.test.tsx | 2 ++ frontend/src/utils/rawPacketStats.ts | 12 ------- 4 files changed, 20 insertions(+), 30 deletions(-) diff --git a/frontend/src/components/RawPacketFeedView.tsx b/frontend/src/components/RawPacketFeedView.tsx index 6b04df4..4dd6a9b 100644 --- a/frontend/src/components/RawPacketFeedView.tsx +++ b/frontend/src/components/RawPacketFeedView.tsx @@ -171,24 +171,17 @@ function isNeighborIdentityResolvable(item: NeighborStat, contacts: Contact[]): return resolveContact(item.key, contacts) !== null; } -function formatStrongestPacketDetail( +function formatStrongestNeighborDetail( stats: ReturnType, contacts: Contact[] ): string | undefined { - if (!stats.strongestPacketPayloadType) { + const strongestNeighbor = stats.strongestNeighbors[0]; + if (!strongestNeighbor || strongestNeighbor.bestRssi === null) { return undefined; } - const resolvedLabel = - resolveContactLabel(stats.strongestPacketSourceKey, contacts) ?? - stats.strongestPacketSourceLabel; - if (resolvedLabel) { - return `${resolvedLabel} · ${stats.strongestPacketPayloadType}`; - } - if (stats.strongestPacketPayloadType === 'GroupText') { - return ' · GroupText'; - } - return stats.strongestPacketPayloadType; + const resolvedNeighbor = resolveNeighbor(strongestNeighbor, contacts); + return `${formatRssi(resolvedNeighbor.bestRssi)} best heard`; } function getCoverageMessage( @@ -450,8 +443,13 @@ export function RawPacketFeedView({ [nowSec, rawPacketStatsSession, selectedWindow] ); const coverageMessage = getCoverageMessage(stats, rawPacketStatsSession); - const strongestPacketDetail = useMemo( - () => formatStrongestPacketDetail(stats, contacts), + const strongestNeighbor = useMemo(() => { + const topNeighbor = stats.strongestNeighbors[0]; + return topNeighbor ? resolveNeighbor(topNeighbor, contacts) : null; + }, [contacts, stats]); + + const strongestNeighborDetail = useMemo( + () => formatStrongestNeighborDetail(stats, contacts), [contacts, stats] ); const strongestNeighbors = useMemo( @@ -578,9 +576,9 @@ export function RawPacketFeedView({ detail={`${formatPercent(stats.pathBearingRate)} path-bearing packets`} /> { it('ignores invalid or embedded channel-like text', () => { expect( - findLinkedChannelReferences('skip #Bad #bad--name abc#ops #ops- #opsRoom #ops_room #good-room,') + findLinkedChannelReferences( + 'skip #Bad #bad--name abc#ops #ops- #opsRoom #ops_room #good-room,' + ) ).toEqual([]); }); }); diff --git a/frontend/src/test/rawPacketFeedView.test.tsx b/frontend/src/test/rawPacketFeedView.test.tsx index 434ad2e..8acb5e5 100644 --- a/frontend/src/test/rawPacketFeedView.test.tsx +++ b/frontend/src/test/rawPacketFeedView.test.tsx @@ -283,6 +283,8 @@ describe('RawPacketFeedView', () => { fireEvent.click(screen.getByRole('button', { name: /show stats/i })); fireEvent.change(screen.getByLabelText('Stats window'), { target: { value: 'session' } }); expect(screen.getAllByText('Alpha').length).toBeGreaterThan(0); + expect(screen.getByText('Strongest Neighbor')).toBeInTheDocument(); + expect(screen.getByText('-70 dBm best heard')).toBeInTheDocument(); }); it('marks unresolved neighbor identities explicitly', () => { diff --git a/frontend/src/utils/rawPacketStats.ts b/frontend/src/utils/rawPacketStats.ts index 66d9d30..86abaac 100644 --- a/frontend/src/utils/rawPacketStats.ts +++ b/frontend/src/utils/rawPacketStats.ts @@ -106,9 +106,6 @@ export interface RawPacketStatsSnapshot { medianRssi: number | null; bestRssi: number | null; rssiBuckets: RankedPacketStat[]; - strongestPacketSourceKey: string | null; - strongestPacketSourceLabel: string | null; - strongestPacketPayloadType: string | null; coverageSeconds: number; windowFullyCovered: boolean; oldestStoredTimestamp: number | null; @@ -377,8 +374,6 @@ export function buildRawPacketStatsSnapshot( ['Weak (<-85 dBm)', 0], ]); - let strongestPacket: RawPacketStatsObservation | null = null; - for (const packet of packets) { payloadCounts.set(packet.payloadType, (payloadCounts.get(packet.payloadType) ?? 0) + 1); routeCounts.set(packet.routeType, (routeCounts.get(packet.routeType) ?? 0) + 1); @@ -436,10 +431,6 @@ export function buildRawPacketStatsSnapshot( } else { rssiBucketCounts.set('Weak (<-85 dBm)', (rssiBucketCounts.get('Weak (<-85 dBm)') ?? 0) + 1); } - - if (!strongestPacket || strongestPacket.rssi === null || packet.rssi > strongestPacket.rssi) { - strongestPacket = packet; - } } } @@ -527,9 +518,6 @@ export function buildRawPacketStatsSnapshot( medianRssi, bestRssi, rssiBuckets: rankedBreakdown(rssiBucketCounts, rssiValues.length), - strongestPacketSourceKey: strongestPacket?.sourceKey ?? null, - strongestPacketSourceLabel: strongestPacket?.sourceLabel ?? null, - strongestPacketPayloadType: strongestPacket?.payloadType ?? null, coverageSeconds, windowFullyCovered, oldestStoredTimestamp, From b74137dc72481ba57524aed8c7122a099bd02a30 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Tue, 31 Mar 2026 16:55:21 -0700 Subject: [PATCH 05/17] Add trace clear and much better layout. Closes #139. --- frontend/src/components/TracePane.tsx | 56 ++++++++++++++++++++------- 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/TracePane.tsx b/frontend/src/components/TracePane.tsx index 54c2da1..d3492ac 100644 --- a/frontend/src/components/TracePane.tsx +++ b/frontend/src/components/TracePane.tsx @@ -318,8 +318,8 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps) : []; return ( -
-
+
+

Trace

Build a repeater loop and trace it back to the local radio. The selectable hop list only @@ -329,7 +329,7 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)

-
+

Repeater Hops

Search by name or key, then add repeaters in the order you want to traverse them. @@ -446,14 +446,30 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)

-
-
-

Trace Path

-

- The first node is display-only. The terminal node is the local radio. -

+
+
+
+

Trace Path

+

+ The first node is display-only. The terminal node is the local radio. +

+
+ {draftHops.length > 0 ? ( + + ) : null}
-
+
-
+
{draftHops.length === 0 ? 'No hops selected' @@ -555,12 +571,26 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
-
+

Results{result ? ` (${result.timeout_seconds.toFixed(1)}s)` : ''}

+ {result || error ? ( + + ) : null}
-
+
{error ? (
{error} From 8e1e913fcdb1bf518d73a88327037e9c7de699ed Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Tue, 31 Mar 2026 18:26:04 -0700 Subject: [PATCH 06/17] Make all scripts executable --- scripts/build/collect_licenses.sh | 0 scripts/build/print_frontend_licenses.cjs | 0 scripts/build/publish.sh | 0 scripts/quality/all_quality.sh | 0 scripts/quality/docker_ci.sh | 0 scripts/quality/e2e.sh | 0 scripts/quality/extended_quality.sh | 0 scripts/setup/fetch_prebuilt_frontend.py | 0 scripts/setup/install_docker.sh | 0 scripts/setup/install_service.sh | 0 10 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 scripts/build/collect_licenses.sh mode change 100644 => 100755 scripts/build/print_frontend_licenses.cjs mode change 100644 => 100755 scripts/build/publish.sh mode change 100644 => 100755 scripts/quality/all_quality.sh mode change 100644 => 100755 scripts/quality/docker_ci.sh mode change 100644 => 100755 scripts/quality/e2e.sh mode change 100644 => 100755 scripts/quality/extended_quality.sh mode change 100644 => 100755 scripts/setup/fetch_prebuilt_frontend.py mode change 100644 => 100755 scripts/setup/install_docker.sh mode change 100644 => 100755 scripts/setup/install_service.sh diff --git a/scripts/build/collect_licenses.sh b/scripts/build/collect_licenses.sh old mode 100644 new mode 100755 diff --git a/scripts/build/print_frontend_licenses.cjs b/scripts/build/print_frontend_licenses.cjs old mode 100644 new mode 100755 diff --git a/scripts/build/publish.sh b/scripts/build/publish.sh old mode 100644 new mode 100755 diff --git a/scripts/quality/all_quality.sh b/scripts/quality/all_quality.sh old mode 100644 new mode 100755 diff --git a/scripts/quality/docker_ci.sh b/scripts/quality/docker_ci.sh old mode 100644 new mode 100755 diff --git a/scripts/quality/e2e.sh b/scripts/quality/e2e.sh old mode 100644 new mode 100755 diff --git a/scripts/quality/extended_quality.sh b/scripts/quality/extended_quality.sh old mode 100644 new mode 100755 diff --git a/scripts/setup/fetch_prebuilt_frontend.py b/scripts/setup/fetch_prebuilt_frontend.py old mode 100644 new mode 100755 diff --git a/scripts/setup/install_docker.sh b/scripts/setup/install_docker.sh old mode 100644 new mode 100755 diff --git a/scripts/setup/install_service.sh b/scripts/setup/install_service.sh old mode 100644 new mode 100755 From 8446d99df1f688ffa96277e2d9295de8a3fc468e Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Tue, 31 Mar 2026 20:23:07 -0700 Subject: [PATCH 07/17] Be more resistent for colons in the device ID --- docker-compose.example.yml | 3 ++- scripts/setup/install_docker.sh | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 2fbeeb3..4a8c6b6 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -16,7 +16,8 @@ services: # Map your radio by stable device ID if available. # ################################################ devices: - - /dev/serial/by-id/your-meshcore-radio:/dev/meshcore-radio + - source: /dev/serial/by-id/your-meshcore-radio + target: /dev/meshcore-radio environment: MESHCORE_DATABASE_PATH: data/meshcore.db diff --git a/scripts/setup/install_docker.sh b/scripts/setup/install_docker.sh index a6951f4..0e68cb4 100755 --- a/scripts/setup/install_docker.sh +++ b/scripts/setup/install_docker.sh @@ -419,7 +419,8 @@ mkdir -p "$REPO_DIR/data" fi if [ "$TRANSPORT_MODE" = "serial" ]; then echo " devices:" - echo " - ${SERIAL_HOST_PATH}:${SERIAL_CONTAINER_PATH}" + echo " - source: $(yaml_quote "$SERIAL_HOST_PATH")" + echo " target: $(yaml_quote "$SERIAL_CONTAINER_PATH")" fi if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then echo " command:" From b52431616e861add7fb574d2636f1809a24231e0 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Tue, 31 Mar 2026 20:47:10 -0700 Subject: [PATCH 08/17] Be more sane with by-id aliases --- README.md | 12 ++++--- docker-compose.example.yml | 12 ++++--- scripts/setup/install_docker.sh | 59 +++++++++++++++++++++++++-------- 3 files changed, 60 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 9fc8976..ccd51a4 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,8 @@ Source checkouts expect a normal frontend build in `frontend/dist`. Local Docker builds are architecture-native by default. On Apple Silicon Macs and ARM64 Linux hosts such as Raspberry Pi, `docker compose build` / `docker compose up --build` will produce an ARM64 image unless you override the platform. +For serial-device passthrough, use rootful Docker. In practice that usually means starting the stack with `sudo docker compose ...` unless your Docker daemon is already configured for rootful access via your user/group. Rootless Docker has been observed to fail on serial-device mappings even when the compose file itself is correct. + Create a local `docker-compose.yml` in one of two ways: 1. Copy the example file and edit it by hand: @@ -128,7 +130,7 @@ The guided Docker flow can collect BLE settings, but BLE access from Docker stil Then customize the local compose file for your transport and launch: ```bash -docker compose up # -d for background once you validate it's working +sudo docker compose up # add -d for background once you validate it's working ``` The database is stored in `./data/` (bind-mounted), so the container shares the same database as the native app. @@ -136,8 +138,8 @@ The database is stored in `./data/` (bind-mounted), so the container shares the To rebuild after pulling updates: ```bash -docker compose pull -docker compose up -d +sudo docker compose pull +sudo docker compose up -d ``` The example file and setup script default to the published Docker Hub image. To build locally from your checkout instead, replace: @@ -155,7 +157,7 @@ build: . Then run: ```bash -docker compose up -d --build +sudo docker compose up -d --build ``` The container runs as root by default for maximum serial passthrough compatibility across host setups. On Linux, if you switch between native and Docker runs, `./data` can end up root-owned. If you do not need that serial compatibility behavior, you can enable the optional `user: "${UID:-1000}:${GID:-1000}"` line in `docker-compose.yml` to keep ownership aligned with your host user. @@ -163,7 +165,7 @@ The container runs as root by default for maximum serial passthrough compatibili To stop: ```bash -docker compose down +sudo docker compose down ``` ## Standard Environment Variables diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 4a8c6b6..2cfce92 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -12,12 +12,14 @@ services: volumes: - ./data:/app/data - ################################################ - # Map your radio by stable device ID if available. # - ################################################ + ##################################################################### + # Map your radio by stable device ID if available. # + # If your by-id path contains ':' characters, Docker Compose cannot # + # represent it here directly; use a colon-free host alias instead. # + # (e.g. /dev/ttyUSB0) # + ##################################################################### devices: - - source: /dev/serial/by-id/your-meshcore-radio - target: /dev/meshcore-radio + - /dev/serial/by-id/your-meshcore-radio:/dev/meshcore-radio environment: MESHCORE_DATABASE_PATH: data/meshcore.db diff --git a/scripts/setup/install_docker.sh b/scripts/setup/install_docker.sh index 0e68cb4..8f65c79 100755 --- a/scripts/setup/install_docker.sh +++ b/scripts/setup/install_docker.sh @@ -32,6 +32,7 @@ SNAKEOIL_KEY_CONTAINER_PATH="/app/certs/$SNAKEOIL_KEY_BASENAME" IMAGE_MODE="image" TRANSPORT_MODE="serial" SERIAL_HOST_PATH="/dev/ttyACM0" +SERIAL_COMPOSE_HOST_PATH="/dev/ttyACM0" SERIAL_CONTAINER_PATH="/dev/meshcore-radio" TCP_HOST="" TCP_PORT="4000" @@ -98,6 +99,35 @@ yaml_quote() { printf "'%s'" "$value" } +normalize_serial_host_path_for_compose() { + local selected_path="$1" + local resolved_path="" + + if [[ "$selected_path" != *:* ]]; then + SERIAL_COMPOSE_HOST_PATH="$selected_path" + return 0 + fi + + resolved_path="$(readlink -f "$selected_path" 2>/dev/null || true)" + if [ -z "$resolved_path" ]; then + echo -e "${RED}Error:${NC} the selected serial path contains ':' and could not be resolved to a raw /dev/tty-style device path." + echo "Selected path: $selected_path" + echo "Please enter the raw serial device path instead (for example /dev/ttyACM0)." + exit 1 + fi + + if [[ "$resolved_path" == *:* ]]; then + echo -e "${RED}Error:${NC} the selected serial path still resolves to a path containing ':', which Docker Compose cannot use here." + echo "Selected path: $selected_path" + echo "Resolved path: $resolved_path" + echo "Please enter the raw serial device path instead (for example /dev/ttyACM0)." + exit 1 + fi + + echo -e "${YELLOW}Note:${NC} the selected serial path contains ':', so Docker Compose will use the resolved raw device path instead: ${resolved_path}" + SERIAL_COMPOSE_HOST_PATH="$resolved_path" +} + detect_primary_local_ip() { local ip="" local iface="" @@ -275,7 +305,8 @@ case "$TRANSPORT_CHOICE" in fi fi - echo -e "${GREEN}Serial passthrough: ${SERIAL_HOST_PATH} -> ${SERIAL_CONTAINER_PATH}${NC}" + normalize_serial_host_path_for_compose "$SERIAL_HOST_PATH" + echo -e "${GREEN}Serial passthrough: ${SERIAL_COMPOSE_HOST_PATH} -> ${SERIAL_CONTAINER_PATH}${NC}" ;; 2) TRANSPORT_MODE="tcp" @@ -419,8 +450,7 @@ mkdir -p "$REPO_DIR/data" fi if [ "$TRANSPORT_MODE" = "serial" ]; then echo " devices:" - echo " - source: $(yaml_quote "$SERIAL_HOST_PATH")" - echo " target: $(yaml_quote "$SERIAL_CONTAINER_PATH")" + echo " - ${SERIAL_COMPOSE_HOST_PATH}:${SERIAL_CONTAINER_PATH}" fi if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then echo " command:" @@ -462,15 +492,18 @@ echo -e "${GREEN}Generated ${COMPOSE_FILE}.${NC}" echo echo -e "${BOLD}Docker commands${NC}" if [ "$IMAGE_MODE" = "build" ]; then - echo " docker compose up -d --build # build the local image and start RemoteTerm in the background" + echo " sudo docker compose up -d --build # build the local image and start RemoteTerm in the background" else - echo " docker compose up -d # start RemoteTerm in the background" + echo " sudo docker compose up -d # start RemoteTerm in the background" fi -echo " docker compose logs -f # follow the container logs live" +echo " sudo docker compose logs -f # follow the container logs live" echo -echo " docker compose down # stop and remove the running container" -echo " docker compose restart # restart the container without changing the image" -echo " docker compose pull && docker compose up -d # upgrade to the latest published image and restart" +echo " sudo docker compose down # stop and remove the running container" +echo " sudo docker compose restart # restart the container without changing the image" +echo " sudo docker compose pull && sudo docker compose up -d # upgrade to the latest published image and restart" +echo +echo -e "${YELLOW}Note:${NC} serial passthrough generally needs ${BOLD}rootful Docker${NC}." +echo "If Docker is running rootless on this host, serial-device mappings may fail even with a valid compose file." if [ "$TRANSPORT_MODE" = "ble" ] || [ "$BLE_MANUAL_WARNING" = true ]; then echo echo -e "${RED}BLE requires more than the generated env vars.${NC}" @@ -480,9 +513,9 @@ echo echo -e "${GREEN}Your new docker file is ready at ${COMPOSE_FILE}.${NC}" echo -e "${GREEN}Feel free to edit it by hand as desired, or:${NC}" echo -echo -e "${PURPLE}┌──────────────────────────────────────────────┐${NC}" -echo -e "${PURPLE}│ Run ${GREEN}${BOLD}docker compose up -d${NC}${PURPLE} to get started. │${NC}" -echo -e "${PURPLE}└──────────────────────────────────────────────┘${NC}" +echo -e "${PURPLE}┌───────────────────────────────────────────────┐${NC}" +echo -e "${PURPLE}│ Run ${GREEN}${BOLD}sudo docker compose up -d${NC}${PURPLE} to get started. │${NC}" +echo -e "${PURPLE}└───────────────────────────────────────────────┘${NC}" if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then echo echo -e "After the container starts, open ${CYAN}https://${LOCAL_ACCESS_IP}:8000${NC}." @@ -492,4 +525,4 @@ else echo -e "After the container starts, open ${CYAN}http://${LOCAL_ACCESS_IP}:8000${NC}." fi echo "If the interface does not appear, follow the logs with:" -echo " docker compose logs -f" +echo " sudo docker compose logs -f" From e631f9b0cc5b1edbf337b91e231b4f6b9a74778d Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Tue, 31 Mar 2026 21:33:50 -0700 Subject: [PATCH 09/17] DHCP notes --- scripts/setup/install_docker.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/setup/install_docker.sh b/scripts/setup/install_docker.sh index 8f65c79..6df6d35 100755 --- a/scripts/setup/install_docker.sh +++ b/scripts/setup/install_docker.sh @@ -518,11 +518,11 @@ echo -e "${PURPLE}│ Run ${GREEN}${BOLD}sudo docker compose up -d${NC}${PURPLE} echo -e "${PURPLE}└───────────────────────────────────────────────┘${NC}" if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then echo - echo -e "After the container starts, open ${CYAN}https://${LOCAL_ACCESS_IP}:8000${NC}." + echo -e "After the container starts, open ${CYAN}https://${LOCAL_ACCESS_IP}:8000${NC}. Note that this address may change if you use DHCP/have not configured a static IP for your host via your router." echo -e "${YELLOW}Expect an untrusted/self-signed certificate warning the first time you connect.${NC}" else echo - echo -e "After the container starts, open ${CYAN}http://${LOCAL_ACCESS_IP}:8000${NC}." + echo -e "After the container starts, open ${CYAN}http://${LOCAL_ACCESS_IP}:8000${NC}. Note that this address may change if you use DHCP/have not configured a static IP for your host via your router." fi -echo "If the interface does not appear, follow the logs with:" +echo "If the interface does not appear, follow the logs to view errors with:" echo " sudo docker compose logs -f" From fccde36ecb356fb66be1d9511970d61611596cca Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Tue, 31 Mar 2026 21:57:35 -0700 Subject: [PATCH 10/17] Gentle emphasis on new contact/channel button --- frontend/src/components/Sidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 0ae9bf1..6ba6e60 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -860,7 +860,7 @@ export function Sidebar({ onClick={onNewMessage} title="Add channel or contact" aria-label="Add channel or contact" - className="h-8 w-full justify-start gap-2 px-3 text-[13px]" + className="h-8 w-full justify-start gap-2 border-primary/20 bg-primary/5 px-3 text-[13px] text-primary hover:bg-primary/10 hover:text-primary" > Add Channel/Contact From e43584912bbca8c3c9672ca1875864d02a818f97 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Tue, 31 Mar 2026 22:14:58 -0700 Subject: [PATCH 11/17] Updating changelog + build for 3.6.4 --- CHANGELOG.md | 7 +++++++ frontend/package.json | 2 +- pyproject.toml | 2 +- tests/e2e/specs/create-contact.spec.ts | 2 +- tests/e2e/specs/create-hashtag.spec.ts | 4 ++-- tests/e2e/specs/historical-decryption.spec.ts | 2 +- uv.lock | 2 +- 7 files changed, 14 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0dc948..facd90c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [3.6.4] - 2026-03-31 + +Feature: Clarify New Channel/Contact button +Bugfix: Rename "Best RSSI" to "Strongest Neighbor" +Bugfix: Improve layout of Trace pane +Misc: Docker setup improvements + ## [3.6.3] - 2026-03-30 Feature: Add multi-byte trace diff --git a/frontend/package.json b/frontend/package.json index 2edf5fc..df0ec31 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "remoteterm-meshcore-frontend", "private": true, - "version": "3.6.3", + "version": "3.6.4", "type": "module", "scripts": { "dev": "vite", diff --git a/pyproject.toml b/pyproject.toml index de26eb0..3e6a01e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "remoteterm-meshcore" -version = "3.6.3" +version = "3.6.4" description = "RemoteTerm - Web interface for MeshCore radio mesh networks" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/e2e/specs/create-contact.spec.ts b/tests/e2e/specs/create-contact.spec.ts index 357dd59..65a46ae 100644 --- a/tests/e2e/specs/create-contact.spec.ts +++ b/tests/e2e/specs/create-contact.spec.ts @@ -19,7 +19,7 @@ test.describe('Create contact flow', () => { await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible(); // Open new message modal - await page.getByTitle('New Message').click(); + await page.getByRole('button', { name: /add channel or contact/i }).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible(); diff --git a/tests/e2e/specs/create-hashtag.spec.ts b/tests/e2e/specs/create-hashtag.spec.ts index 66206d0..9df3e25 100644 --- a/tests/e2e/specs/create-hashtag.spec.ts +++ b/tests/e2e/specs/create-hashtag.spec.ts @@ -26,7 +26,7 @@ test.describe('Create hashtag channel flow', () => { await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible(); // Open new message modal - await page.getByTitle('New Message').click(); + await page.getByRole('button', { name: /add channel or contact/i }).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible(); @@ -49,7 +49,7 @@ test.describe('Create hashtag channel flow', () => { await page.goto('/'); await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible(); - await page.getByTitle('New Message').click(); + await page.getByRole('button', { name: /add channel or contact/i }).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible(); diff --git a/tests/e2e/specs/historical-decryption.spec.ts b/tests/e2e/specs/historical-decryption.spec.ts index 4403abe..9a08b02 100644 --- a/tests/e2e/specs/historical-decryption.spec.ts +++ b/tests/e2e/specs/historical-decryption.spec.ts @@ -37,7 +37,7 @@ test.describe('Historical packet decryption', () => { await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible(); // Open new message modal → Hashtag tab - await page.getByTitle('New Message').click(); + await page.getByRole('button', { name: /add channel or contact/i }).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible(); await dialog.getByRole('tab', { name: /Hashtag/i }).click(); diff --git a/uv.lock b/uv.lock index ff2edd1..e8f5421 100644 --- a/uv.lock +++ b/uv.lock @@ -1098,7 +1098,7 @@ wheels = [ [[package]] name = "remoteterm-meshcore" -version = "3.6.3" +version = "3.6.4" source = { virtual = "." } dependencies = [ { name = "aiomqtt" }, From 97aade363281fb796dd1988e2ce63b2d756b6f66 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Tue, 31 Mar 2026 22:17:03 -0700 Subject: [PATCH 12/17] Format changelog entries with bullets --- CHANGELOG.md | 676 +++++++++++++++++++-------------------- scripts/build/publish.sh | 15 +- 2 files changed, 349 insertions(+), 342 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index facd90c..bcf6f11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,179 +1,179 @@ ## [3.6.4] - 2026-03-31 -Feature: Clarify New Channel/Contact button -Bugfix: Rename "Best RSSI" to "Strongest Neighbor" -Bugfix: Improve layout of Trace pane -Misc: Docker setup improvements +* Feature: Clarify New Channel/Contact button +* Bugfix: Rename "Best RSSI" to "Strongest Neighbor" +* Bugfix: Improve layout of Trace pane +* Misc: Docker setup improvements ## [3.6.3] - 2026-03-30 -Feature: Add multi-byte trace -Feature: Show node name on discovered node if we know it -Feature: Add docker installation script -Feature: Add historical noise floor to stats -Feature: Add trace tool -Bugfix: 100x performance on statistics endpoint with indices and better queries -Misc: Performance and correctness improvements for backend-of-the-frontend -Misc: Reorganize scripts +* Feature: Add multi-byte trace +* Feature: Show node name on discovered node if we know it +* Feature: Add docker installation script +* Feature: Add historical noise floor to stats +* Feature: Add trace tool +* Bugfix: 100x performance on statistics endpoint with indices and better queries +* Misc: Performance and correctness improvements for backend-of-the-frontend +* Misc: Reorganize scripts ## [3.6.2] - 2026-03-29 -Feature: Be more flexible about timing and volume of full contact offload -Feature: Improve room server and repeater ops to be much more clearer about auth status -Feature: Show last error status on integrations -Feature: Push multi-platform docker builds -Bugfix: Fix advert interval time unit display -Bugfix: Don't cast RSSI/SNR to string for community MQTT -Bugfix: Map uploader follows redirect -Misc: Thin out unnecessary cruft in unreads endpoint -Misc: Fall back gracefully if linked to an unknown contact +* Feature: Be more flexible about timing and volume of full contact offload +* Feature: Improve room server and repeater ops to be much more clearer about auth status +* Feature: Show last error status on integrations +* Feature: Push multi-platform docker builds +* Bugfix: Fix advert interval time unit display +* Bugfix: Don't cast RSSI/SNR to string for community MQTT +* Bugfix: Map uploader follows redirect +* Misc: Thin out unnecessary cruft in unreads endpoint +* Misc: Fall back gracefully if linked to an unknown contact ## [3.6.1] - 2026-03-26 -Feature: MeshCore Map integration -Feature: Add warning screen about bots -Feature: Favicon reflects unread message state -Feature: Show hop map in larger modal -Feature: Add prebuilt frontend install script -Feature: Add clean service installer script -Feature: Swipe in to show menu -Bugfix: Invalid backend API path serves error, not fallback index -Bugfix: Fix some spacing/page height issues -Misc: Misc. bugfixes and performance and test improvements +* Feature: MeshCore Map integration +* Feature: Add warning screen about bots +* Feature: Favicon reflects unread message state +* Feature: Show hop map in larger modal +* Feature: Add prebuilt frontend install script +* Feature: Add clean service installer script +* Feature: Swipe in to show menu +* Bugfix: Invalid backend API path serves error, not fallback index +* Bugfix: Fix some spacing/page height issues +* Misc: Misc. bugfixes and performance and test improvements ## [3.6.0] - 2026-03-22 -Feature: Add incoming-packet analytics -Feature: BYOPacket for analysis -Feature: Add room activity to stats view -Bugfix: Handle Heltec v3 serial noise -Misc: Swap repeaters and room servers for better ordering +* Feature: Add incoming-packet analytics +* Feature: BYOPacket for analysis +* Feature: Add room activity to stats view +* Bugfix: Handle Heltec v3 serial noise +* Misc: Swap repeaters and room servers for better ordering ## [3.5.0] - 2026-03-19 -Feature: Add room server alpha support -Feature: Add option to force-reset node clock when it's too far ahead -Feature: DMs auto-retry before resorting to flood -Feature: Add impulse zero-hop advert -Feature: Utilize PATH packets to correctly source a contact's route -Feature: Metrics view on raw packet pane -Feature: Metric, Imperial, and Smoots are now selectable for distance display -Feature: Allow favorites to be sorted -Feature: Add multi-ack support -Feature: Password-remember checkbox on repeaters + room servers -Bugfix: Serialize radio disconnect in a lock -Bugfix: Fix contact bar layout issues -Bugfix: Fix sidebar ordering for contacts by advert recency -Bugfix: Fix version reporting in community MQTT -Bugfix: Fix Apprise duplicate names -Bugfix: Be better about identity resolution in the stats pane -Misc: Docs, test, and performance enhancements -Misc: Don't prompt "Are you sure" when leaving an unedited interation -Misc: Log node time on startup -Misc: Improve community MQTT error bubble-up -Misc: Unread DMs always have a red unread counter -Misc: Improve information in the debug view to show DB status +* Feature: Add room server alpha support +* Feature: Add option to force-reset node clock when it's too far ahead +* Feature: DMs auto-retry before resorting to flood +* Feature: Add impulse zero-hop advert +* Feature: Utilize PATH packets to correctly source a contact's route +* Feature: Metrics view on raw packet pane +* Feature: Metric, Imperial, and Smoots are now selectable for distance display +* Feature: Allow favorites to be sorted +* Feature: Add multi-ack support +* Feature: Password-remember checkbox on repeaters + room servers +* Bugfix: Serialize radio disconnect in a lock +* Bugfix: Fix contact bar layout issues +* Bugfix: Fix sidebar ordering for contacts by advert recency +* Bugfix: Fix version reporting in community MQTT +* Bugfix: Fix Apprise duplicate names +* Bugfix: Be better about identity resolution in the stats pane +* Misc: Docs, test, and performance enhancements +* Misc: Don't prompt "Are you sure" when leaving an unedited interation +* Misc: Log node time on startup +* Misc: Improve community MQTT error bubble-up +* Misc: Unread DMs always have a red unread counter +* Misc: Improve information in the debug view to show DB status ## [3.4.1] - 2026-03-16 -Bugfix: Improve handling of version information on prebuilt bundles -Bugfix: Improve frontend usability on disconnected radio -Misc: Docs and readme updates -Misc: Overhaul DM ingest and frontend state handling +* Bugfix: Improve handling of version information on prebuilt bundles +* Bugfix: Improve frontend usability on disconnected radio +* Misc: Docs and readme updates +* Misc: Overhaul DM ingest and frontend state handling ## [3.4.0] - 2026-03-16 -Feature: Add radio model and stats display -Feature: Add prebuilt frontends, then deleted that and moved to prebuilt release artifacts -Bugfix: Misc. frontend performance and correctness fixes -Bugfix: Fix same-second same-content DM send collition -Bugfix: Discard clearly-wrong GPS data -Bugfix: Prevent repeater clock skew drift on page nav -Misc: Use repeater's advertised location if we haven't loaded one from repeater admin -Misc: Don't permit invalid fanout configs to be saved ever` +* Feature: Add radio model and stats display +* Feature: Add prebuilt frontends, then deleted that and moved to prebuilt release artifacts +* Bugfix: Misc. frontend performance and correctness fixes +* Bugfix: Fix same-second same-content DM send collition +* Bugfix: Discard clearly-wrong GPS data +* Bugfix: Prevent repeater clock skew drift on page nav +* Misc: Use repeater's advertised location if we haven't loaded one from repeater admin +* Misc: Don't permit invalid fanout configs to be saved ever` ## [3.3.0] - 2026-03-13 -Feature: Use dashed lines to show collapsed ambiguous router results -Feature: Jump to unred -Feature: Local channel management to prevent need to reload channel every time -Feature: Debug endpoint -Feature: Force-singleton channel management -Feature: Local node discovery -Feature: Node routing discovery -Bugfix: Don't tell users to us npm ci -Bugfix: Fallback polling dm message persistence -Bugfix: All native-JS inputs are now modals -Bugfix: Same-second send collision resolution -Bugfix: Proper browser updates on resend -Bugfix: Don't use last-heard when we actually want last-advert for path discovery for nodes -Bugfix: Don't treat prefix-matching DM echoes as acks like we do for channel messages -Misc: Visualizer data layer overhaul for future map work -Misc: Parallelize docker tests +* Feature: Use dashed lines to show collapsed ambiguous router results +* Feature: Jump to unred +* Feature: Local channel management to prevent need to reload channel every time +* Feature: Debug endpoint +* Feature: Force-singleton channel management +* Feature: Local node discovery +* Feature: Node routing discovery +* Bugfix: Don't tell users to us npm ci +* Bugfix: Fallback polling dm message persistence +* Bugfix: All native-JS inputs are now modals +* Bugfix: Same-second send collision resolution +* Bugfix: Proper browser updates on resend +* Bugfix: Don't use last-heard when we actually want last-advert for path discovery for nodes +* Bugfix: Don't treat prefix-matching DM echoes as acks like we do for channel messages +* Misc: Visualizer data layer overhaul for future map work +* Misc: Parallelize docker tests ## [3.2.0] - 2026-03-12 -Feature: Improve ambiguous-sender DM handling and visibility -Feature: Allow for toggling of node GPS broadcast -Feature: Add path width to bot and move example to full kwargs -Feature: Improve node map color contrast -Bugfix: More accurate tracking of contact data -Bugfix: Misc. frontend performance and bugfixes -Misc: Clearer warnings on user-key linkage -Misc: Documentation improvements +* Feature: Improve ambiguous-sender DM handling and visibility +* Feature: Allow for toggling of node GPS broadcast +* Feature: Add path width to bot and move example to full kwargs +* Feature: Improve node map color contrast +* Bugfix: More accurate tracking of contact data +* Bugfix: Misc. frontend performance and bugfixes +* Misc: Clearer warnings on user-key linkage +* Misc: Documentation improvements ## [3.1.1] - 2026-03-11 -Feature: Add basic auth -Feature: SQS fanout -Feature: Enrich contact info pane -Feature: Search operators for node and channel -Feature: Pause radio connection attempts from Radio settings -Feature: New themes! What a great use of time! -Feature: Github workflows runs for validation -Bugfix: More consistent log format with times -Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses +* Feature: Add basic auth +* Feature: SQS fanout +* Feature: Enrich contact info pane +* Feature: Search operators for node and channel +* Feature: Pause radio connection attempts from Radio settings +* Feature: New themes! What a great use of time! +* Feature: Github workflows runs for validation +* Bugfix: More consistent log format with times +* Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses ## [3.1.0] - 2026-03-11 -Feature: Add basic auth -Feature: SQS fanout -Feature: Enrich contact info pane -Feature: Search operators for node and channel -Feature: Pause radio connection attempts from Radio settings -Feature: New themes! What a great use of time! -Feature: Github workflows runs for validation -Bugfix: More consistent log format with times -Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses +* Feature: Add basic auth +* Feature: SQS fanout +* Feature: Enrich contact info pane +* Feature: Search operators for node and channel +* Feature: Pause radio connection attempts from Radio settings +* Feature: New themes! What a great use of time! +* Feature: Github workflows runs for validation +* Bugfix: More consistent log format with times +* Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses ## [3.0.0] - 2026-03-10 -Feature: Custom regions per-channel -Feature: Add custom contact pathing -Feature: Corrupt packets are more clear that they're corrupt -Feature: Better, faster patterns around background fetching with explicit opt-in for recurring sync if the app detects you need it -Feature: More consistent icons -Feature: Add per-channel local notifications -Feature: New themes -Feature: Massive codebase refactor and overhaul -Bugfix: Fix packet parsing for trace packets -Bugfix: Refetch channels on reconnect -Bugfix: Load All on repeater pane on mobile doesn't etend into lower text -Bugfix: Timestamps in logs -Bugfix: Correct wrong clock sync command -Misc: Improve bot error bubble up -Misc: Update to non-lib-included meshcore-decoder version -Misc: Revise refactors to be more LLM friendly -Misc: Fix script executability -Misc: Better logging format with timestamp -Misc: Repeater advert buttons separate flood and one-hop -Misc: Preserve repeater pane on navigation away -Misc: Clearer iconography and coloring for status bar buttons -Misc: Search bar to top bar +* Feature: Custom regions per-channel +* Feature: Add custom contact pathing +* Feature: Corrupt packets are more clear that they're corrupt +* Feature: Better, faster patterns around background fetching with explicit opt-in for recurring sync if the app detects you need it +* Feature: More consistent icons +* Feature: Add per-channel local notifications +* Feature: New themes +* Feature: Massive codebase refactor and overhaul +* Bugfix: Fix packet parsing for trace packets +* Bugfix: Refetch channels on reconnect +* Bugfix: Load All on repeater pane on mobile doesn't etend into lower text +* Bugfix: Timestamps in logs +* Bugfix: Correct wrong clock sync command +* Misc: Improve bot error bubble up +* Misc: Update to non-lib-included meshcore-decoder version +* Misc: Revise refactors to be more LLM friendly +* Misc: Fix script executability +* Misc: Better logging format with timestamp +* Misc: Repeater advert buttons separate flood and one-hop +* Misc: Preserve repeater pane on navigation away +* Misc: Clearer iconography and coloring for status bar buttons +* Misc: Search bar to top bar ## [2.7.9] - 2026-03-08 -Bugfix: Don't obscure new integration dropdown on session boundary +* Bugfix: Don't obscure new integration dropdown on session boundary ## [2.7.8] - 2026-03-08 @@ -181,287 +181,287 @@ Bugfix: Don't obscure new integration dropdown on session boundary ## [2.7.8] - 2026-03-08 -Bugfix: Improve frontend asset resolution and fixup the build/push script +* Bugfix: Improve frontend asset resolution and fixup the build/push script ## [2.7.1] - 2026-03-08 -Bugfix: Fix historical DM packet length passing -Misc: Follow better inclusion patterns for the patched meshcore-decoder and just publish the dang package -Misc: Patch a bewildering browser quirk that cause large raw packet lists to extend past the bottom of the page +* Bugfix: Fix historical DM packet length passing +* Misc: Follow better inclusion patterns for the patched meshcore-decoder and just publish the dang package +* Misc: Patch a bewildering browser quirk that cause large raw packet lists to extend past the bottom of the page ## [2.7.0] - 2026-03-08 -Feature: Multibyte path support -Feature: Add multibyte statistics to statistics pane -Feature: Add path bittage to contact info pane -Feature: Put tools in a collapsible +* Feature: Multibyte path support +* Feature: Add multibyte statistics to statistics pane +* Feature: Add path bittage to contact info pane +* Feature: Put tools in a collapsible ## [2.6.1] - 2026-03-08 -Misc: Fix busted docker builds; we don't have a 2.6.0 build sorry +* Misc: Fix busted docker builds; we don't have a 2.6.0 build sorry ## [2.6.0] - 2026-03-08 -Feature: A11y improvements -Feature: New themes -Feature: Backfill channel sender identity when available -Feature: Modular fanout bus, including Webhooks, more customizable community MQTT, and Apprise -Bugfix: Unreads now respect blocklist -Bugfix: Unreads can't accumulate on an open thread -Bugfix: Channel name in broadcasts -Bugfix: Add missing httpx dependency -Bugfix: Improvements to radio startup frontend-blocking time and radio status reporting -Misc: Improved button signage for app movement -Misc: Test, performance, and documentation improvements +* Feature: A11y improvements +* Feature: New themes +* Feature: Backfill channel sender identity when available +* Feature: Modular fanout bus, including Webhooks, more customizable community MQTT, and Apprise +* Bugfix: Unreads now respect blocklist +* Bugfix: Unreads can't accumulate on an open thread +* Bugfix: Channel name in broadcasts +* Bugfix: Add missing httpx dependency +* Bugfix: Improvements to radio startup frontend-blocking time and radio status reporting +* Misc: Improved button signage for app movement +* Misc: Test, performance, and documentation improvements ## [2.5.0] - 2026-03-05 -Feature: Far better accessibility across the app (with far to go) -Feature: Add community MQTT stats reporting, and improve over a few commits -Feature: Color schemes and misc. settings reorg -Feature: Add why-active to filtered nodes -Feature: Add channel and contact info box -Feature: Add contact blocking -Feature: Add potential repeater path map display -Feature: Add flood scoping/regions -Feature: Global message search -Feature: Fully safe bot disable -Feature: Add default #remoteterm channel (lol sorry I had to) -Feature: Custom recency pruning in visualizer -Bugfix: Be more cautious around null byte stripping -Bugfix: Clear channel-add interface on not-add-another -Bugfix: Add status/name/MQTT LWT -Bugfix: Channel deletion propagates over WS -Bugfix: Show map location for all nodes on link, not 7-day-limited -Bugfix: Hide private key channel keys by default -Misc: Logline to show if cleanup loop on non-sync'd meshcore radio links fixes anything -Misc: Doc, changelog, and test improvements -Misc: Add, and remove, package lock (sorry Windows users) -Misc: Don't show mark all as read if not necessary -Misc: Fix stale closures and misc. frontend perf/correctness improvements -Misc: Add Windows startup notes -Misc: E2E expansion + improvement -Misc: Move around visualizer settings +* Feature: Far better accessibility across the app (with far to go) +* Feature: Add community MQTT stats reporting, and improve over a few commits +* Feature: Color schemes and misc. settings reorg +* Feature: Add why-active to filtered nodes +* Feature: Add channel and contact info box +* Feature: Add contact blocking +* Feature: Add potential repeater path map display +* Feature: Add flood scoping/regions +* Feature: Global message search +* Feature: Fully safe bot disable +* Feature: Add default #remoteterm channel (lol sorry I had to) +* Feature: Custom recency pruning in visualizer +* Bugfix: Be more cautious around null byte stripping +* Bugfix: Clear channel-add interface on not-add-another +* Bugfix: Add status/name/MQTT LWT +* Bugfix: Channel deletion propagates over WS +* Bugfix: Show map location for all nodes on link, not 7-day-limited +* Bugfix: Hide private key channel keys by default +* Misc: Logline to show if cleanup loop on non-sync'd meshcore radio links fixes anything +* Misc: Doc, changelog, and test improvements +* Misc: Add, and remove, package lock (sorry Windows users) +* Misc: Don't show mark all as read if not necessary +* Misc: Fix stale closures and misc. frontend perf/correctness improvements +* Misc: Add Windows startup notes +* Misc: E2E expansion + improvement +* Misc: Move around visualizer settings ## [2.4.0] - 2026-03-02 -Feature: Add community MQTT reporting (e.g. LetsMesh.net) -Misc: Build scripts and library attribution -Misc: Add sign of life to E2E tests +* Feature: Add community MQTT reporting (e.g. LetsMesh.net) +* Misc: Build scripts and library attribution +* Misc: Add sign of life to E2E tests ## [2.3.0] - 2026-03-01 -Feature: Click path description to reset to flood -Feature: Add MQTT publishing -Feature: Visualizer remembers settings -Bugfix: Fix prefetch usage -Bugfix: Fixed an issue where busy channels can result in double-display of incoming messages -Misc: Drop py3.12 requirement -Misc: Performance, documentation, test, and file structure optimizations -Misc: Add arrows between route nodes on contact info -Misc: Show repeater path/type in title bar +* Feature: Click path description to reset to flood +* Feature: Add MQTT publishing +* Feature: Visualizer remembers settings +* Bugfix: Fix prefetch usage +* Bugfix: Fixed an issue where busy channels can result in double-display of incoming messages +* Misc: Drop py3.12 requirement +* Misc: Performance, documentation, test, and file structure optimizations +* Misc: Add arrows between route nodes on contact info +* Misc: Show repeater path/type in title bar ## [2.2.0] - 2026-02-28 -Feature: Track advert paths and use to disambiguate repeater identity in visualizer -Feature: Contact info pane -Feature: Overhaul repeater interface -Bugfix: Misc. frontend rendering + perf improvements -Bugfix: Better behavior around radio locking and autofetch/polling -Bugfix: Clear channel name field on new-channel modal tab change -Bugfix: Repeater inforbox can scroll -Bugfix: Better handling of historical DM encrypts -Bugfix: Handle errors if returned in prefetch phase -Misc: Radio event response failure is logged/surfaced better -Misc: Improve test coverage and remove dead code -Misc: Documentation and errata improvements -Misc: Database storage optimization +* Feature: Track advert paths and use to disambiguate repeater identity in visualizer +* Feature: Contact info pane +* Feature: Overhaul repeater interface +* Bugfix: Misc. frontend rendering + perf improvements +* Bugfix: Better behavior around radio locking and autofetch/polling +* Bugfix: Clear channel name field on new-channel modal tab change +* Bugfix: Repeater inforbox can scroll +* Bugfix: Better handling of historical DM encrypts +* Bugfix: Handle errors if returned in prefetch phase +* Misc: Radio event response failure is logged/surfaced better +* Misc: Improve test coverage and remove dead code +* Misc: Documentation and errata improvements +* Misc: Database storage optimization ## [2.1.0] - 2026-02-23 -Feature: Add ability to remember last-used channel on load -Feature: Add `docker compose` support (thanks @suymur !) -Feature: Better-aligned favicon (lol) -Bugfix: Disable autocomplete on message field -Bugfix: Legacy hash restoration on page load -Bugfix: Align resend buttons in pathing modal -Bugfix: Update README.md (briefly), then docker-compose.yaml, to reflect correct docker image host -Bugfix: Correct settings pane scroll lock on zoom (thanks @yellowcooln !) -Bugfix: Improved repeater comms on busy meshes -Bugfix: Drain before autofetch from radio -Bugfix: Fix, or document exceptions to, sub-second resolution message failure -Bugfix: Improved handling of radio connection, disconnection, and connection-aliveness-status -Bugfix: Force server-side keystore update when radio key changes -Bugfix: Reduce WS churn for incoming message handling -Bugfix: Fix content type signalling for irrelevant endpoints -Bugfix: Handle stuck post-connect failure state -Misc: Documentation & version parsing improvements -Misc: Hide char counter on mobile for short messages -Misc: Typo fixes in docs and settings -Misc: Add dynamic webmanifest for hosts that can support it -Misc: Improve DB size via dropping unnecessary uniqs, indices, vacuum, and offering ability to drop historical matches packets -Misc: Drop weird rounded bounding box for settings -Misc: Move resend buttons to pathing modal -Misc: Improved comments around database ownership on *nix systems -Misc: Move to SSoT for message dedupe on frontend -Misc: Move DM ack clearing to standard poll, and increase hold time between polling -Misc: Holistic testing overhaul +* Feature: Add ability to remember last-used channel on load +* Feature: Add `docker compose` support (thanks @suymur !) +* Feature: Better-aligned favicon (lol) +* Bugfix: Disable autocomplete on message field +* Bugfix: Legacy hash restoration on page load +* Bugfix: Align resend buttons in pathing modal +* Bugfix: Update README.md (briefly), then docker-compose.yaml, to reflect correct docker image host +* Bugfix: Correct settings pane scroll lock on zoom (thanks @yellowcooln !) +* Bugfix: Improved repeater comms on busy meshes +* Bugfix: Drain before autofetch from radio +* Bugfix: Fix, or document exceptions to, sub-second resolution message failure +* Bugfix: Improved handling of radio connection, disconnection, and connection-aliveness-status +* Bugfix: Force server-side keystore update when radio key changes +* Bugfix: Reduce WS churn for incoming message handling +* Bugfix: Fix content type signalling for irrelevant endpoints +* Bugfix: Handle stuck post-connect failure state +* Misc: Documentation & version parsing improvements +* Misc: Hide char counter on mobile for short messages +* Misc: Typo fixes in docs and settings +* Misc: Add dynamic webmanifest for hosts that can support it +* Misc: Improve DB size via dropping unnecessary uniqs, indices, vacuum, and offering ability to drop historical matches packets +* Misc: Drop weird rounded bounding box for settings +* Misc: Move resend buttons to pathing modal +* Misc: Improved comments around database ownership on *nix systems +* Misc: Move to SSoT for message dedupe on frontend +* Misc: Move DM ack clearing to standard poll, and increase hold time between polling +* Misc: Holistic testing overhaul ## [2.0.1] - 2026-02-16 -Bugfix: Fix missing trigger condition on statistics pane expansion on mobile +* Bugfix: Fix missing trigger condition on statistics pane expansion on mobile ## [2.0.0] - 2026-02-16 -Feature: Frontend UX + log overhaul -Bugfix: Use contact object from DB for broadcast rather than handrolling -Bugfix: Fix out of order path WS messages overwriting each other -Bugfix: Make broadcast timestamp match fallback logic used in storage code -Bugfix: Fix repeater command timestamp selection logic -Bugfix: Use actual pubkey matching for path update, and don't action serial path update events (use RX packet) -Bugfix: Add missing radio operation locks in a few spots -Bugfix: Fix dedupe for frontend raw packet delivery (mesh visualizer much more active now!) -Bugfix: Less aggressive dedupe for advert packets (we don't care about the payload, we care about the path, duh) -Misc: Visualizer layout refinement & option labels +* Feature: Frontend UX + log overhaul +* Bugfix: Use contact object from DB for broadcast rather than handrolling +* Bugfix: Fix out of order path WS messages overwriting each other +* Bugfix: Make broadcast timestamp match fallback logic used in storage code +* Bugfix: Fix repeater command timestamp selection logic +* Bugfix: Use actual pubkey matching for path update, and don't action serial path update events (use RX packet) +* Bugfix: Add missing radio operation locks in a few spots +* Bugfix: Fix dedupe for frontend raw packet delivery (mesh visualizer much more active now!) +* Bugfix: Less aggressive dedupe for advert packets (we don't care about the payload, we care about the path, duh) +* Misc: Visualizer layout refinement & option labels ## [1.10.0] - 2026-02-16 -Feature: Collapsible sidebar sections with per-section unread badge (thanks @rgregg !) -Feature: 3D mesh visualizer -Feature: Statistics pane -Feature: Support incoming/outgoing indication for bot invocations -Feature: Quick byte-perfect message resend if you got unlucky with repeats (thanks @rgregg -- we had a parallel implementation but I appreciate your work!) -Bugfix: Fix top padding out outgoing message -Bugfix: Frontend performance, appearance, and Lighthouse improvements (prefetches, form labelling, contrast, channel/roomlist changes) -Bugfix: Multiple-sent messages had path appearing delays until rerender -Bugfix: Fix ack/message race condition that caused dropped ack displays until rerender -Misc: Dedupe contacts/rooms by key and not name to prevent name collisions creating unreachable conversations -Misc: s/stopped/idle/ for room finder +* Feature: Collapsible sidebar sections with per-section unread badge (thanks @rgregg !) +* Feature: 3D mesh visualizer +* Feature: Statistics pane +* Feature: Support incoming/outgoing indication for bot invocations +* Feature: Quick byte-perfect message resend if you got unlucky with repeats (thanks @rgregg -- we had a parallel implementation but I appreciate your work!) +* Bugfix: Fix top padding out outgoing message +* Bugfix: Frontend performance, appearance, and Lighthouse improvements (prefetches, form labelling, contrast, channel/roomlist changes) +* Bugfix: Multiple-sent messages had path appearing delays until rerender +* Bugfix: Fix ack/message race condition that caused dropped ack displays until rerender +* Misc: Dedupe contacts/rooms by key and not name to prevent name collisions creating unreachable conversations +* Misc: s/stopped/idle/ for room finder ## [1.9.3] - 2026-02-12 -Feature: Upgrade the room finder to support two-word rooms +* Feature: Upgrade the room finder to support two-word rooms ## [1.9.2] - 2026-02-12 -Feature: Options dialog sucks less -Bugfix: Move tests to isolated memory DB -Bugfix: Mention case sensitivity -Bugfix: Stale header retention on settings page view -Bugfix: Non-isolated path writing -Bugfix: Nullable contact fields are now passed as real nulls -Bugfix: Look at all fields on message reconcile, not just text -Bugfix: Make mark-all-as-read atomic -Misc: Purge unused WS handlers from back when we did chans and contacts over WS, not API -Misc: Massive test and AGENTS.md overhauls and additions +* Feature: Options dialog sucks less +* Bugfix: Move tests to isolated memory DB +* Bugfix: Mention case sensitivity +* Bugfix: Stale header retention on settings page view +* Bugfix: Non-isolated path writing +* Bugfix: Nullable contact fields are now passed as real nulls +* Bugfix: Look at all fields on message reconcile, not just text +* Bugfix: Make mark-all-as-read atomic +* Misc: Purge unused WS handlers from back when we did chans and contacts over WS, not API +* Misc: Massive test and AGENTS.md overhauls and additions ## [1.9.1] - 2026-02-10 -Feature: Contacts and channels use keys, not names -Bugfix: Fix falsy casting of 0 in lat lon and timing data -Bugfix: Show message length in bytes, not chars -Bugfix: Fix phantom unread badges on focused convos -Misc: Bot invocation to async -Misc: Use full key, not prefix, where we can +* Feature: Contacts and channels use keys, not names +* Bugfix: Fix falsy casting of 0 in lat lon and timing data +* Bugfix: Show message length in bytes, not chars +* Bugfix: Fix phantom unread badges on focused convos +* Misc: Bot invocation to async +* Misc: Use full key, not prefix, where we can ## [1.9.0] - 2026-02-10 -Feature: Favorited contacts are preferentially loaded onto the radio -Feature: Add recent-message caching for fast switching -Feature: Add echo paths modal when echo-heard checkbox is clicked -Feature: Add experimental byte-perfect double-send for bad RF environments to try to punch the message out +* Feature: Favorited contacts are preferentially loaded onto the radio +* Feature: Add recent-message caching for fast switching +* Feature: Add echo paths modal when echo-heard checkbox is clicked +* Feature: Add experimental byte-perfect double-send for bad RF environments to try to punch the message out Frontend: Better styling on echo + message path display -Bugfix: Prevent frontend static file serving path traversal vuln -Bugfix: Safer prefix-claiming for DMs we don't have the key for -Bugfix: Prevent injection from mentions with special characters -Bugfix: Fix repeaters comms showing in wrong channel when repeater operations are in flight and the channel is changed quickly -Bugfix: App can boot and test without a frontend dir -Misc: Improve and consistent-ify (?) backend radio operation lock management -Misc: Frontend performance and safety enhancements -Misc: Move builds to non-bundled; usage requires building the Frontend -Misc: Update tests and agent docs +* Bugfix: Prevent frontend static file serving path traversal vuln +* Bugfix: Safer prefix-claiming for DMs we don't have the key for +* Bugfix: Prevent injection from mentions with special characters +* Bugfix: Fix repeaters comms showing in wrong channel when repeater operations are in flight and the channel is changed quickly +* Bugfix: App can boot and test without a frontend dir +* Misc: Improve and consistent-ify (?) backend radio operation lock management +* Misc: Frontend performance and safety enhancements +* Misc: Move builds to non-bundled; usage requires building the Frontend +* Misc: Update tests and agent docs ## [1.8.0] - 2026-02-07 -Feature: Single hop ping -Feature: PWA viewport fixes(thanks @rgregg) +* Feature: Single hop ping +* Feature: PWA viewport fixes(thanks @rgregg) Feature (?): No frontend distribution; build it yourself ;P -Bugfix: Fix channel message send race condition (concurrent sends could corrupt shared radio slot) -Bugfix: Fix TOCTOU race in radio reconnect (duplicate connections under contention) -Bugfix: Better guarding around reconnection -Bugfix: Duplicate websocket connection fixes -Bugfix: Settings tab error cleanliness on tab swap -Bugfix: Fix path traversal vuln +* Bugfix: Fix channel message send race condition (concurrent sends could corrupt shared radio slot) +* Bugfix: Fix TOCTOU race in radio reconnect (duplicate connections under contention) +* Bugfix: Better guarding around reconnection +* Bugfix: Duplicate websocket connection fixes +* Bugfix: Settings tab error cleanliness on tab swap +* Bugfix: Fix path traversal vuln UI: Swap visualizer legend ordering (yay prettier) -Misc: Perf and locking improvements -Misc: Always flood advertisements -Misc: Better packet dupe handling -Misc: Dead code cleanup, test improvements +* Misc: Perf and locking improvements +* Misc: Always flood advertisements +* Misc: Better packet dupe handling +* Misc: Dead code cleanup, test improvements ## [1.7.1] - 2026-02-03 -Feature: Clickable hyperlinks -Bugfix: More consistent public key normalization -Bugfix: Use more reliable cursor paging -Bugfix: Fix null timestamp dedupe failure -Bugfix: More consistent prefix-based message claiming on key receipt -Misc: Bot can respond to its own messages -Misc: Additional tests -Misc: Remove unneeded message dedupe logic -Misc: Resync settings after radio settings mutation +* Feature: Clickable hyperlinks +* Bugfix: More consistent public key normalization +* Bugfix: Use more reliable cursor paging +* Bugfix: Fix null timestamp dedupe failure +* Bugfix: More consistent prefix-based message claiming on key receipt +* Misc: Bot can respond to its own messages +* Misc: Additional tests +* Misc: Remove unneeded message dedupe logic +* Misc: Resync settings after radio settings mutation ## [1.7.0] - 2026-01-27 -Feature: Multi-bot functionality -Bugfix: Adjust bot code editor display and add line numbers -Bugfix: Fix clock filtering and contact lookup behavior bugs -Bugfix: Fix repeater message duplication issue -Bugfix: Correct outbound message timestamp assignment (affecting outgoing messages seen as incoming) +* Feature: Multi-bot functionality +* Bugfix: Adjust bot code editor display and add line numbers +* Bugfix: Fix clock filtering and contact lookup behavior bugs +* Bugfix: Fix repeater message duplication issue +* Bugfix: Correct outbound message timestamp assignment (affecting outgoing messages seen as incoming) UI: Move advertise button to identity tab -Misc: Clarify fallback functionality for missing private key export in logs +* Misc: Clarify fallback functionality for missing private key export in logs ## [1.6.0] - 2026-01-26 -Feature: Visualizer: extract public key from AnonReq, add heuristic repeater disambiguation, add reset button, draggable nodes -Feature: Customizable advertising interval -Feature: In-app bot setup -Bugfix: Force contact onto radio before DM send -Misc: Remove unused code +* Feature: Visualizer: extract public key from AnonReq, add heuristic repeater disambiguation, add reset button, draggable nodes +* Feature: Customizable advertising interval +* Feature: In-app bot setup +* Bugfix: Force contact onto radio before DM send +* Misc: Remove unused code ## [1.5.0] - 2026-01-19 -Feature: Network visualizer +* Feature: Network visualizer ## [1.4.1] - 2026-01-19 -Feature: Add option to attempt historical DM decrypt on new-contact advertisement (disabled by default) -Feature: Server-side preference management for favorites, read status, etc. +* Feature: Add option to attempt historical DM decrypt on new-contact advertisement (disabled by default) +* Feature: Server-side preference management for favorites, read status, etc. UI: More compact hop labelling -Bugfix: Misc. race conditions and websocket handling -Bugfix: Reduce fetching cadence by loading all contact data at start to prevent fetches on advertise-driven update +* Bugfix: Misc. race conditions and websocket handling +* Bugfix: Reduce fetching cadence by loading all contact data at start to prevent fetches on advertise-driven update ## [1.4.0] - 2026-01-18 UI: Improve button layout for room searcher UI: Improve favicon coloring UI: Improve status bar button layout on small screen -Feature: Show multi-path hop display with distance estimates -Feature: Search rooms and contacts by key, not just name -Bugfix: Historical DM decryption now works as expected -Bugfix: Don't double-set active conversation after addition; wait for backend room name normalization +* Feature: Show multi-path hop display with distance estimates +* Feature: Search rooms and contacts by key, not just name +* Bugfix: Historical DM decryption now works as expected +* Bugfix: Don't double-set active conversation after addition; wait for backend room name normalization ## [1.3.1] - 2026-01-17 UI: Rework restart handling -Feature: Add `dutycyle_start` command to logged-in repeater session to start five min duty cycle tracking +* Feature: Add `dutycyle_start` command to logged-in repeater session to start five min duty cycle tracking Bug: Improve error message rendering from server-side errors UI: Remove octothorpe from channel listing ## [1.3.0] - 2026-01-17 -Feature: Rework database schema to drop unnecessary columns and dedupe payloads at the DB level -Feature: Massive frontend settings overhaul. It ain't gorgeous but it's easier to navigate. -Feature: Drop repeater login wait time; vestigial from debugging a different issue +* Feature: Rework database schema to drop unnecessary columns and dedupe payloads at the DB level +* Feature: Massive frontend settings overhaul. It ain't gorgeous but it's easier to navigate. +* Feature: Drop repeater login wait time; vestigial from debugging a different issue ## [1.2.1] - 2026-01-17 @@ -469,27 +469,27 @@ Update: Update meshcore-hashtag-cracker to include sender-identification correct ## [1.2.0] - 2026-01-16 -Feature: Add favorites +* Feature: Add favorites ## [1.1.0] - 2026-01-14 -Bugfix: Use actual pathing data from advertisements, not just always flood (oops) -Bugfix: Autosync radio clock periodically to prevent drift (would show up most commonly as issues with repeater comms) +* Bugfix: Use actual pathing data from advertisements, not just always flood (oops) +* Bugfix: Autosync radio clock periodically to prevent drift (would show up most commonly as issues with repeater comms) ## [1.0.3] - 2026-01-13 -Bugfix: Add missing test management packages -Improvement: Drop unnecessary repeater timeouts, and retain timeout for login only -- repeater ops are faster AND more reliable! +* Bugfix: Add missing test management packages +* Improvement: Drop unnecessary repeater timeouts, and retain timeout for login only -- repeater ops are faster AND more reliable! ## [1.0.2] - 2026-01-13 -Improvement: Add delays between router ops to prevent traffic collisions +* Improvement: Add delays between router ops to prevent traffic collisions ## [1.0.1] - 2026-01-13 -Bugixes: Cleaner DB shutdown, radio reconnect contention, packet dedupe garbage removal +* Bugixes: Cleaner DB shutdown, radio reconnect contention, packet dedupe garbage removal ## [1.0.0] - 2026-01-13 -Initial full release! +* Initial full release! diff --git a/scripts/build/publish.sh b/scripts/build/publish.sh index e886751..c98bc0e 100755 --- a/scripts/build/publish.sh +++ b/scripts/build/publish.sh @@ -143,6 +143,13 @@ echo CHANGELOG_ENTRY=$(cat) +format_changelog_entry() { + local entry="$1" + printf '%s\n' "$entry" | sed '/^[[:space:]]*$/d; s/^[[:space:]]*//; s/^/* /' +} + +FORMATTED_CHANGELOG_ENTRY=$(format_changelog_entry "$CHANGELOG_ENTRY") + # Create changelog entry with date DATE=$(date +%Y-%m-%d) CHANGELOG_HEADER="## [$VERSION] - $DATE" @@ -157,7 +164,7 @@ if [ -f CHANGELOG.md ]; then echo echo "$CHANGELOG_HEADER" echo - echo "$CHANGELOG_ENTRY" + echo "$FORMATTED_CHANGELOG_ENTRY" echo tail -n +2 CHANGELOG.md } > CHANGELOG.md.tmp @@ -167,7 +174,7 @@ if [ -f CHANGELOG.md ]; then { echo "$CHANGELOG_HEADER" echo - echo "$CHANGELOG_ENTRY" + echo "$FORMATTED_CHANGELOG_ENTRY" echo cat CHANGELOG.md } > CHANGELOG.md.tmp @@ -180,7 +187,7 @@ else echo echo "$CHANGELOG_HEADER" echo - echo "$CHANGELOG_ENTRY" + echo "$FORMATTED_CHANGELOG_ENTRY" } > CHANGELOG.md fi @@ -247,7 +254,7 @@ RELEASE_NOTES_FILE=$(mktemp) { echo "$CHANGELOG_HEADER" echo - echo "$CHANGELOG_ENTRY" + echo "$FORMATTED_CHANGELOG_ENTRY" } > "$RELEASE_NOTES_FILE" # Create and push the release tag first so GitHub release creation does not From 96e108037c2d78efb1ee5eefa3038c0be09fca8b Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Tue, 31 Mar 2026 22:21:06 -0700 Subject: [PATCH 13/17] Updating changelog + build for 3.6.5 --- CHANGELOG.md | 4 ++++ frontend/package.json | 2 +- pyproject.toml | 2 +- scripts/build/publish.sh | 39 ++++++++++++++++----------------------- uv.lock | 2 +- 5 files changed, 23 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bcf6f11..af7c4ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## [3.6.5] - 2026-03-31 + +* Bugfix: Maybe fix problem with publish script + ## [3.6.4] - 2026-03-31 * Feature: Clarify New Channel/Contact button diff --git a/frontend/package.json b/frontend/package.json index df0ec31..728caff 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "remoteterm-meshcore-frontend", "private": true, - "version": "3.6.4", + "version": "3.6.5", "type": "module", "scripts": { "dev": "vite", diff --git a/pyproject.toml b/pyproject.toml index 3e6a01e..89fc772 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "remoteterm-meshcore" -version = "3.6.4" +version = "3.6.5" description = "RemoteTerm - Web interface for MeshCore radio mesh networks" readme = "README.md" requires-python = ">=3.10" diff --git a/scripts/build/publish.sh b/scripts/build/publish.sh index c98bc0e..6b4dbd8 100755 --- a/scripts/build/publish.sh +++ b/scripts/build/publish.sh @@ -30,28 +30,6 @@ cleanup_release_build_artifacts() { trap cleanup_release_build_artifacts EXIT -ensure_buildx_builder() { - if ! docker buildx version >/dev/null 2>&1; then - echo -e "${RED}Error: docker buildx is required for multi-arch Docker builds.${NC}" - exit 1 - fi - - local current_builder - current_builder="$(docker buildx inspect --format '{{ .Name }}' 2>/dev/null || true)" - - if [ -n "$current_builder" ]; then - docker buildx inspect --bootstrap >/dev/null - return - fi - - if docker buildx inspect remoteterm-multiarch >/dev/null 2>&1; then - docker buildx use remoteterm-multiarch >/dev/null - else - docker buildx create --name remoteterm-multiarch --use >/dev/null - fi - docker buildx inspect --bootstrap >/dev/null -} - echo -e "${YELLOW}=== RemoteTerm for MeshCore Publish Script ===${NC}" echo @@ -236,7 +214,22 @@ echo # Build and push multi-arch docker image echo -e "${YELLOW}Building and pushing multi-arch Docker image...${NC}" -ensure_buildx_builder +if ! docker buildx version >/dev/null 2>&1; then + echo -e "${RED}Error: docker buildx is required for multi-arch Docker builds.${NC}" + exit 1 +fi + +CURRENT_BUILDER="$(docker buildx inspect --format '{{ .Name }}' 2>/dev/null || true)" +if [ -n "$CURRENT_BUILDER" ]; then + docker buildx inspect --bootstrap >/dev/null +elif docker buildx inspect remoteterm-multiarch >/dev/null 2>&1; then + docker buildx use remoteterm-multiarch >/dev/null + docker buildx inspect --bootstrap >/dev/null +else + docker buildx create --name remoteterm-multiarch --use >/dev/null + docker buildx inspect --bootstrap >/dev/null +fi + docker buildx build \ --platform "$DOCKER_PLATFORMS" \ --build-arg COMMIT_HASH="$GIT_HASH" \ diff --git a/uv.lock b/uv.lock index e8f5421..56fb427 100644 --- a/uv.lock +++ b/uv.lock @@ -1098,7 +1098,7 @@ wheels = [ [[package]] name = "remoteterm-meshcore" -version = "3.6.4" +version = "3.6.5" source = { virtual = "." } dependencies = [ { name = "aiomqtt" }, From 704a3d8a87caf30058c9fde2f37e722abfef3888 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Tue, 31 Mar 2026 22:52:14 -0700 Subject: [PATCH 14/17] Updating changelog + build for 3.6.6 --- CHANGELOG.md | 4 + frontend/package.json | 2 +- pyproject.toml | 2 +- scripts/build/create_github_release.sh | 106 +++++++++ scripts/build/extract_release_notes.sh | 54 +++++ scripts/build/package_release_artifact.sh | 111 ++++++++++ scripts/build/publish.sh | 249 ++++++++-------------- scripts/build/push_docker_multiarch.sh | 79 +++++++ scripts/build/release_common.sh | 93 ++++++++ uv.lock | 2 +- 10 files changed, 544 insertions(+), 158 deletions(-) create mode 100644 scripts/build/create_github_release.sh create mode 100644 scripts/build/extract_release_notes.sh create mode 100644 scripts/build/package_release_artifact.sh create mode 100644 scripts/build/push_docker_multiarch.sh create mode 100644 scripts/build/release_common.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index af7c4ca..e0a2e06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## [3.6.6] - 2026-03-31 + +* Misc: Please I'm begging for the build scripts to be working now + ## [3.6.5] - 2026-03-31 * Bugfix: Maybe fix problem with publish script diff --git a/frontend/package.json b/frontend/package.json index 728caff..3fb60c9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "remoteterm-meshcore-frontend", "private": true, - "version": "3.6.5", + "version": "3.6.6", "type": "module", "scripts": { "dev": "vite", diff --git a/pyproject.toml b/pyproject.toml index 89fc772..40c0e7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "remoteterm-meshcore" -version = "3.6.5" +version = "3.6.6" description = "RemoteTerm - Web interface for MeshCore radio mesh networks" readme = "README.md" requires-python = ">=3.10" diff --git a/scripts/build/create_github_release.sh b/scripts/build/create_github_release.sh new file mode 100644 index 0000000..7dfae59 --- /dev/null +++ b/scripts/build/create_github_release.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=scripts/build/release_common.sh +source "$SCRIPT_DIR/release_common.sh" + +usage() { + cat <<'EOF' +Usage: scripts/build/create_github_release.sh --version X.Y.Z --asset PATH [options] + +Options: + --version VERSION Release version / tag (required) + --asset PATH Asset to attach; may be specified multiple times + --notes-file PATH Markdown release notes file; defaults to CHANGELOG section + --full-git-hash HASH Commit to tag if the tag does not already exist locally + --title TITLE Release title (default: version) + --help Show this message +EOF +} + +VERSION="" +TITLE="" +NOTES_FILE="" +FULL_GIT_HASH="" +ASSETS=() +TEMP_NOTES_FILE="" + +cleanup() { + if [ -n "$TEMP_NOTES_FILE" ] && [ -f "$TEMP_NOTES_FILE" ]; then + rm -f "$TEMP_NOTES_FILE" + fi +} +trap cleanup EXIT + +while [ $# -gt 0 ]; do + case "$1" in + --version) + VERSION="${2:-}" + shift 2 + ;; + --asset) + ASSETS+=("${2:-}") + shift 2 + ;; + --notes-file) + NOTES_FILE="${2:-}" + shift 2 + ;; + --full-git-hash) + FULL_GIT_HASH="${2:-}" + shift 2 + ;; + --title) + TITLE="${2:-}" + shift 2 + ;; + --help) + usage + exit 0 + ;; + *) + usage >&2 + release_die "Unknown argument: $1" + ;; + esac +done + +[ -n "$VERSION" ] || release_die "--version is required" +[ "${#ASSETS[@]}" -gt 0 ] || release_die "At least one --asset is required" +release_validate_version "$VERSION" + +REPO_ROOT="$(release_repo_root)" +TITLE="${TITLE:-$VERSION}" +FULL_GIT_HASH="${FULL_GIT_HASH:-$(release_resolve_full_hash "$REPO_ROOT")}" + +for asset in "${ASSETS[@]}"; do + [ -f "$asset" ] || release_die "Asset not found: $asset" +done + +if [ -z "$NOTES_FILE" ]; then + TEMP_NOTES_FILE="$(mktemp)" + release_extract_changelog_section "$REPO_ROOT" "$VERSION" "$TEMP_NOTES_FILE" + NOTES_FILE="$TEMP_NOTES_FILE" +fi + +[ -f "$NOTES_FILE" ] || release_die "Notes file not found: $NOTES_FILE" + +if ! git -C "$REPO_ROOT" rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then + echo "[create_github_release] Creating local tag $VERSION at $FULL_GIT_HASH..." >&2 + git -C "$REPO_ROOT" tag -a "$VERSION" "$FULL_GIT_HASH" -F "$NOTES_FILE" +fi + +if ! git -C "$REPO_ROOT" ls-remote --exit-code --tags origin "refs/tags/$VERSION" >/dev/null 2>&1; then + echo "[create_github_release] Pushing tag $VERSION to origin..." >&2 + git -C "$REPO_ROOT" push origin "$VERSION" +fi + +if gh release view "$VERSION" >/dev/null 2>&1; then + echo "[create_github_release] Updating existing GitHub release $VERSION..." >&2 + gh release upload "$VERSION" "${ASSETS[@]}" --clobber + gh release edit "$VERSION" --title "$TITLE" --notes-file "$NOTES_FILE" +else + echo "[create_github_release] Creating GitHub release $VERSION..." >&2 + gh release create "$VERSION" "${ASSETS[@]}" --title "$TITLE" --notes-file "$NOTES_FILE" --verify-tag +fi diff --git a/scripts/build/extract_release_notes.sh b/scripts/build/extract_release_notes.sh new file mode 100644 index 0000000..6deb85a --- /dev/null +++ b/scripts/build/extract_release_notes.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=scripts/build/release_common.sh +source "$SCRIPT_DIR/release_common.sh" + +usage() { + cat <<'EOF' +Usage: scripts/build/extract_release_notes.sh --version X.Y.Z --output PATH + +Options: + --version VERSION Release version to extract from CHANGELOG.md + --output PATH Output markdown file path + --changelog PATH Override changelog path + --help Show this message +EOF +} + +VERSION="" +OUTPUT_FILE="" +CHANGELOG_PATH="" + +while [ $# -gt 0 ]; do + case "$1" in + --version) + VERSION="${2:-}" + shift 2 + ;; + --output) + OUTPUT_FILE="${2:-}" + shift 2 + ;; + --changelog) + CHANGELOG_PATH="${2:-}" + shift 2 + ;; + --help) + usage + exit 0 + ;; + *) + usage >&2 + release_die "Unknown argument: $1" + ;; + esac +done + +[ -n "$VERSION" ] || release_die "--version is required" +[ -n "$OUTPUT_FILE" ] || release_die "--output is required" +release_validate_version "$VERSION" + +REPO_ROOT="$(release_repo_root)" +release_extract_changelog_section "$REPO_ROOT" "$VERSION" "$OUTPUT_FILE" "${CHANGELOG_PATH:-$REPO_ROOT/CHANGELOG.md}" diff --git a/scripts/build/package_release_artifact.sh b/scripts/build/package_release_artifact.sh new file mode 100644 index 0000000..5eb2cc1 --- /dev/null +++ b/scripts/build/package_release_artifact.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=scripts/build/release_common.sh +source "$SCRIPT_DIR/release_common.sh" + +usage() { + cat <<'EOF' +Usage: scripts/build/package_release_artifact.sh --version X.Y.Z [options] + +Options: + --version VERSION Release version (required) + --git-hash HASH Short git hash to embed in artifact naming + --full-git-hash HASH Full git hash to archive + --output PATH Output zip path + --bundle-name NAME Bundle folder name inside the zip + --skip-prebuilt-build Reuse existing frontend/prebuilt instead of rebuilding it + --help Show this message +EOF +} + +VERSION="" +GIT_HASH="" +FULL_GIT_HASH="" +OUTPUT_PATH="" +BUNDLE_NAME="Remote-Terminal-for-MeshCore" +SKIP_PREBUILT_BUILD=0 + +while [ $# -gt 0 ]; do + case "$1" in + --version) + VERSION="${2:-}" + shift 2 + ;; + --git-hash) + GIT_HASH="${2:-}" + shift 2 + ;; + --full-git-hash) + FULL_GIT_HASH="${2:-}" + shift 2 + ;; + --output) + OUTPUT_PATH="${2:-}" + shift 2 + ;; + --bundle-name) + BUNDLE_NAME="${2:-}" + shift 2 + ;; + --skip-prebuilt-build) + SKIP_PREBUILT_BUILD=1 + shift + ;; + --help) + usage + exit 0 + ;; + *) + usage >&2 + release_die "Unknown argument: $1" + ;; + esac +done + +[ -n "$VERSION" ] || release_die "--version is required" +release_validate_version "$VERSION" + +REPO_ROOT="$(release_repo_root)" +FULL_GIT_HASH="${FULL_GIT_HASH:-$(release_resolve_full_hash "$REPO_ROOT")}" +GIT_HASH="${GIT_HASH:-$(release_resolve_short_hash "$REPO_ROOT" "$FULL_GIT_HASH")}" +OUTPUT_PATH="${OUTPUT_PATH:-$REPO_ROOT/remoteterm-prebuilt-frontend-v${VERSION}-${GIT_HASH}.zip}" + +WORK_DIR="$(mktemp -d)" +BUNDLE_DIR="$WORK_DIR/$BUNDLE_NAME" + +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +if [ "$SKIP_PREBUILT_BUILD" -eq 0 ]; then + echo "[package_release_artifact] Building frontend prebuilt bundle..." >&2 + ( + cd "$REPO_ROOT/frontend" + npm run packaged-build + ) +fi + +[ -d "$REPO_ROOT/frontend/prebuilt" ] || release_die "frontend/prebuilt is missing; run with frontend built or omit --skip-prebuilt-build" + +mkdir -p "$BUNDLE_DIR/frontend" +git -C "$REPO_ROOT" archive "$FULL_GIT_HASH" | tar -x -C "$BUNDLE_DIR" +cp -R "$REPO_ROOT/frontend/prebuilt" "$BUNDLE_DIR/frontend/prebuilt" + +cat > "$BUNDLE_DIR/build_info.json" <&2 + release_die "Unknown argument: $1" + ;; + esac +done echo -e "${YELLOW}=== RemoteTerm for MeshCore Publish Script ===${NC}" echo -# Run backend linting and type checking -echo -e "${YELLOW}Running backend lint (Ruff)...${NC}" -uv run ruff check app/ tests/ --fix -uv run ruff format app/ tests/ -# validate -uv run ruff check app/ tests/ -uv run ruff format --check app/ tests/ -echo -e "${GREEN}Backend lint passed!${NC}" -echo - -echo -e "${YELLOW}Running backend type check (Pyright)...${NC}" -uv run pyright app/ -echo -e "${GREEN}Backend type check passed!${NC}" -echo - -# Run backend tests -echo -e "${YELLOW}Running backend tests...${NC}" -PYTHONPATH=. uv run pytest tests/ -v -echo -e "${GREEN}Backend tests passed!${NC}" -echo - -# Run frontend linting and formatting check -echo -e "${YELLOW}Running frontend lint (ESLint)...${NC}" -cd "$REPO_ROOT/frontend" -npm run lint -echo -e "${GREEN}Frontend lint passed!${NC}" -echo - -echo -e "${YELLOW}Checking frontend formatting (Prettier)...${NC}" -npm run format:check -echo -e "${GREEN}Frontend formatting OK!${NC}" -echo - -# Run frontend tests and build -echo -e "${YELLOW}Running frontend tests...${NC}" -npm run test:run -echo -e "${GREEN}Frontend tests passed!${NC}" -echo - -echo -e "${YELLOW}Building frontend...${NC}" -npm run build -echo -e "${GREEN}Frontend build complete!${NC}" -cd "$REPO_ROOT" -echo +if [ "$SKIP_QUALITY" -eq 0 ]; then + echo -e "${YELLOW}Running repo quality gate...${NC}" + ./scripts/quality/all_quality.sh + echo -e "${GREEN}Quality gate passed!${NC}" + echo +fi echo -e "${YELLOW}Regenerating LICENSES.md...${NC}" bash scripts/build/collect_licenses.sh LICENSES.md @@ -91,13 +81,11 @@ echo -n " package.json: " grep '"version"' frontend/package.json | head -1 | sed 's/.*"version": "\(.*\)".*/\1/' echo -read -r -p "Enter new version (e.g., 1.2.3): " VERSION -VERSION="$(printf '%s' "$VERSION" | tr -d '\r' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')" - -if [[ ! $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo -e "${RED}Error: Version must be in format X.Y.Z${NC}" - exit 1 +if [ -z "$VERSION" ]; then + read -r -p "Enter new version (e.g., 1.2.3): " VERSION fi +VERSION="$(release_trim "$VERSION")" +release_validate_version "$VERSION" # Update pyproject.toml echo -e "${YELLOW}Updating pyproject.toml...${NC}" @@ -115,18 +103,28 @@ echo -e "${GREEN}Version updated to $VERSION${NC}" echo # Prompt for changelog entry -echo -e "${YELLOW}Enter changelog entry for version $VERSION${NC}" -echo -e "${YELLOW}(Enter your changes, then press Ctrl+D when done):${NC}" -echo - -CHANGELOG_ENTRY=$(cat) - -format_changelog_entry() { - local entry="$1" - printf '%s\n' "$entry" | sed '/^[[:space:]]*$/d; s/^[[:space:]]*//; s/^/* /' +RAW_CHANGELOG_INPUT_FILE="$(mktemp)" +FORMATTED_CHANGELOG_INPUT_FILE="$(mktemp)" +cleanup() { + rm -f "$RAW_CHANGELOG_INPUT_FILE" "$FORMATTED_CHANGELOG_INPUT_FILE" + rm -rf "${REPO_ROOT:?}/frontend/prebuilt" + if [ -n "$RELEASE_ASSET_PATH" ] && [ -f "$RELEASE_ASSET_PATH" ]; then + rm -f "$RELEASE_ASSET_PATH" + fi } +trap cleanup EXIT -FORMATTED_CHANGELOG_ENTRY=$(format_changelog_entry "$CHANGELOG_ENTRY") +if [ -n "$NOTES_FILE" ]; then + cp "$NOTES_FILE" "$RAW_CHANGELOG_INPUT_FILE" +else + echo -e "${YELLOW}Enter changelog entry for version $VERSION${NC}" + echo -e "${YELLOW}(Enter your changes, then press Ctrl+D when done):${NC}" + echo + cat > "$RAW_CHANGELOG_INPUT_FILE" +fi + +release_format_markdown_list "$RAW_CHANGELOG_INPUT_FILE" "$FORMATTED_CHANGELOG_INPUT_FILE" +[ -s "$FORMATTED_CHANGELOG_INPUT_FILE" ] || release_die "Changelog entry cannot be empty" # Create changelog entry with date DATE=$(date +%Y-%m-%d) @@ -142,7 +140,7 @@ if [ -f CHANGELOG.md ]; then echo echo "$CHANGELOG_HEADER" echo - echo "$FORMATTED_CHANGELOG_ENTRY" + cat "$FORMATTED_CHANGELOG_INPUT_FILE" echo tail -n +2 CHANGELOG.md } > CHANGELOG.md.tmp @@ -152,7 +150,7 @@ if [ -f CHANGELOG.md ]; then { echo "$CHANGELOG_HEADER" echo - echo "$FORMATTED_CHANGELOG_ENTRY" + cat "$FORMATTED_CHANGELOG_INPUT_FILE" echo cat CHANGELOG.md } > CHANGELOG.md.tmp @@ -165,7 +163,7 @@ else echo echo "$CHANGELOG_HEADER" echo - echo "$FORMATTED_CHANGELOG_ENTRY" + cat "$FORMATTED_CHANGELOG_INPUT_FILE" } > CHANGELOG.md fi @@ -185,93 +183,33 @@ echo GIT_HASH=$(git rev-parse --short HEAD) FULL_GIT_HASH=$(git rev-parse HEAD) RELEASE_ASSET="remoteterm-prebuilt-frontend-v${VERSION}-${GIT_HASH}.zip" +RELEASE_ASSET_PATH="$REPO_ROOT/$RELEASE_ASSET" echo -e "${YELLOW}Building packaged frontend artifact...${NC}" -cd "$REPO_ROOT/frontend" -npm run packaged-build -cd "$REPO_ROOT" - -RELEASE_WORK_DIR=$(mktemp -d) -RELEASE_BUNDLE_DIR="$RELEASE_WORK_DIR/$RELEASE_BUNDLE_DIR_NAME" -mkdir -p "$RELEASE_BUNDLE_DIR" -git archive "$FULL_GIT_HASH" | tar -x -C "$RELEASE_BUNDLE_DIR" -mkdir -p "$RELEASE_BUNDLE_DIR/frontend" -cp -R "$REPO_ROOT/frontend/prebuilt" "$RELEASE_BUNDLE_DIR/frontend/prebuilt" -cat > "$RELEASE_BUNDLE_DIR/build_info.json" </dev/null 2>&1; then - echo -e "${RED}Error: docker buildx is required for multi-arch Docker builds.${NC}" - exit 1 -fi - -CURRENT_BUILDER="$(docker buildx inspect --format '{{ .Name }}' 2>/dev/null || true)" -if [ -n "$CURRENT_BUILDER" ]; then - docker buildx inspect --bootstrap >/dev/null -elif docker buildx inspect remoteterm-multiarch >/dev/null 2>&1; then - docker buildx use remoteterm-multiarch >/dev/null - docker buildx inspect --bootstrap >/dev/null -else - docker buildx create --name remoteterm-multiarch --use >/dev/null - docker buildx inspect --bootstrap >/dev/null -fi - -docker buildx build \ - --platform "$DOCKER_PLATFORMS" \ - --build-arg COMMIT_HASH="$GIT_HASH" \ - -t "$DOCKER_IMAGE:latest" \ - -t "$DOCKER_IMAGE:$VERSION" \ - -t "$DOCKER_IMAGE:$GIT_HASH" \ - --push \ - . +scripts/build/push_docker_multiarch.sh \ + --version "$VERSION" \ + --git-hash "$GIT_HASH" \ + --image "$DOCKER_IMAGE" \ + --platforms "$DOCKER_PLATFORMS" echo -e "${GREEN}Multi-arch Docker build + push complete!${NC}" echo # Create GitHub release using the changelog notes for this version. echo -e "${YELLOW}Creating GitHub release...${NC}" -RELEASE_NOTES_FILE=$(mktemp) -{ - echo "$CHANGELOG_HEADER" - echo - echo "$FORMATTED_CHANGELOG_ENTRY" -} > "$RELEASE_NOTES_FILE" - -# Create and push the release tag first so GitHub release creation does not -# depend on resolving a symbolic ref like HEAD on the remote side. Use the same -# changelog-derived notes for the annotated tag message. -if git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then - echo -e "${YELLOW}Tag $VERSION already exists locally; reusing it.${NC}" -else - git tag -a "$VERSION" "$FULL_GIT_HASH" -F "$RELEASE_NOTES_FILE" -fi - -if git ls-remote --exit-code --tags origin "refs/tags/$VERSION" >/dev/null 2>&1; then - echo -e "${YELLOW}Tag $VERSION already exists on origin; not pushing it again.${NC}" -else - git push origin "$VERSION" -fi - -gh release create "$VERSION" \ - "$RELEASE_ASSET" \ - --title "$VERSION" \ - --notes-file "$RELEASE_NOTES_FILE" \ - --verify-tag - -rm -f "$RELEASE_NOTES_FILE" +scripts/build/create_github_release.sh \ + --version "$VERSION" \ + --full-git-hash "$FULL_GIT_HASH" \ + --asset "$RELEASE_ASSET_PATH" echo -e "${GREEN}GitHub release created!${NC}" echo @@ -285,6 +223,7 @@ echo -e " - $DOCKER_IMAGE:$GIT_HASH" echo -e "Platforms:" echo -e " - linux/amd64" echo -e " - linux/arm64" +echo -e " - linux/arm/v7" echo -e "GitHub release:" echo -e " - $VERSION" echo -e "Release artifact:" diff --git a/scripts/build/push_docker_multiarch.sh b/scripts/build/push_docker_multiarch.sh new file mode 100644 index 0000000..2c7c4ca --- /dev/null +++ b/scripts/build/push_docker_multiarch.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=scripts/build/release_common.sh +source "$SCRIPT_DIR/release_common.sh" + +usage() { + cat <<'EOF' +Usage: scripts/build/push_docker_multiarch.sh --version X.Y.Z [options] + +Options: + --version VERSION Release version (required) + --git-hash HASH Short git hash to tag alongside the version + --image IMAGE Docker image name (default: docker.io/jkingsman/remoteterm-meshcore) + --platforms CSV Buildx platforms CSV (default: linux/amd64,linux/arm64,linux/arm/v7) + --help Show this message +EOF +} + +VERSION="" +GIT_HASH="" +IMAGE="docker.io/jkingsman/remoteterm-meshcore" +PLATFORMS="linux/amd64,linux/arm64,linux/arm/v7" + +while [ $# -gt 0 ]; do + case "$1" in + --version) + VERSION="${2:-}" + shift 2 + ;; + --git-hash) + GIT_HASH="${2:-}" + shift 2 + ;; + --image) + IMAGE="${2:-}" + shift 2 + ;; + --platforms) + PLATFORMS="${2:-}" + shift 2 + ;; + --help) + usage + exit 0 + ;; + *) + usage >&2 + release_die "Unknown argument: $1" + ;; + esac +done + +[ -n "$VERSION" ] || release_die "--version is required" +release_validate_version "$VERSION" + +REPO_ROOT="$(release_repo_root)" +GIT_HASH="${GIT_HASH:-$(release_resolve_short_hash "$REPO_ROOT")}" + +echo "[push_docker_multiarch] Ensuring docker buildx builder..." >&2 +release_ensure_buildx_builder + +docker_buildx_args=( + build + --platform "$PLATFORMS" + --build-arg "COMMIT_HASH=$GIT_HASH" + -t "$IMAGE:latest" + -t "$IMAGE:$VERSION" + -t "$IMAGE:$GIT_HASH" + --push + . +) + +echo "[push_docker_multiarch] Building and pushing $IMAGE for $PLATFORMS..." >&2 +( + cd "$REPO_ROOT" + docker buildx "${docker_buildx_args[@]}" +) diff --git a/scripts/build/release_common.sh b/scripts/build/release_common.sh new file mode 100644 index 0000000..ad8ae28 --- /dev/null +++ b/scripts/build/release_common.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash + +release_repo_root() { + ( + cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd + ) +} + +release_die() { + echo "Error: $*" >&2 + exit 1 +} + +release_trim() { + printf '%s' "$1" | tr -d '\r' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' +} + +release_validate_version() { + local version="$1" + [[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || release_die "Version must be in format X.Y.Z" +} + +release_resolve_full_hash() { + local repo_root="$1" + local ref="${2:-HEAD}" + git -C "$repo_root" rev-parse "$ref" +} + +release_resolve_short_hash() { + local repo_root="$1" + local ref="${2:-HEAD}" + git -C "$repo_root" rev-parse --short "$ref" +} + +release_format_markdown_list() { + local input_file="$1" + local output_file="$2" + awk ' + /^[[:space:]]*$/ { next } + { + sub(/^[[:space:]]+/, "", $0) + if ($0 ~ /^\* /) { + print + } else if ($0 ~ /^- /) { + sub(/^- /, "* ", $0) + print + } else { + print "* " $0 + } + } + ' "$input_file" > "$output_file" +} + +release_extract_changelog_section() { + local repo_root="$1" + local version="$2" + local output_file="$3" + local changelog_path="${4:-$repo_root/CHANGELOG.md}" + + # Use index() for literal matching so dots in version strings are not + # treated as regex wildcards (e.g. 3.6.5 won't match 31615). + awk -v ver="$version" ' + BEGIN { header = "## [" ver "]" } + index($0, header) == 1 { capture = 1; print; next } + capture && /^## \[/ { exit } + capture { print } + ' "$changelog_path" > "$output_file" + + [ -s "$output_file" ] || release_die "Could not find CHANGELOG entry for version $version" +} + +release_ensure_buildx_builder() { + if ! docker buildx version >/dev/null 2>&1; then + release_die "docker buildx is required for multi-arch Docker builds" + fi + + # Multi-platform builds require the docker-container driver. The default + # builder uses the "docker" driver which only supports the host platform. + # Check the current builder's driver first; only create a new one if needed. + local current_driver + current_driver="$(docker buildx inspect --format '{{ .Driver }}' 2>/dev/null || true)" + if [ "$current_driver" = "docker-container" ]; then + docker buildx inspect --bootstrap >/dev/null + return + fi + + if docker buildx inspect remoteterm-multiarch >/dev/null 2>&1; then + docker buildx use remoteterm-multiarch >/dev/null + else + docker buildx create --name remoteterm-multiarch --use >/dev/null + fi + docker buildx inspect --bootstrap >/dev/null +} diff --git a/uv.lock b/uv.lock index 56fb427..317991f 100644 --- a/uv.lock +++ b/uv.lock @@ -1098,7 +1098,7 @@ wheels = [ [[package]] name = "remoteterm-meshcore" -version = "3.6.5" +version = "3.6.6" source = { virtual = "." } dependencies = [ { name = "aiomqtt" }, From 480798e117bcdabe3372417eb7b6c76cb6b92f39 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Tue, 31 Mar 2026 23:01:36 -0700 Subject: [PATCH 15/17] Updating changelog + build for 3.6.7 --- CHANGELOG.md | 4 ++++ frontend/package.json | 2 +- pyproject.toml | 2 +- scripts/build/publish.sh | 3 +-- scripts/build/push_docker_multiarch.sh | 4 ++-- uv.lock | 2 +- 6 files changed, 10 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0a2e06..8fb699d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## [3.6.7] - 2026-03-31 + +* Misc: Remove armv7 (for now) + ## [3.6.6] - 2026-03-31 * Misc: Please I'm begging for the build scripts to be working now diff --git a/frontend/package.json b/frontend/package.json index 3fb60c9..cf95aa4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "remoteterm-meshcore-frontend", "private": true, - "version": "3.6.6", + "version": "3.6.7", "type": "module", "scripts": { "dev": "vite", diff --git a/pyproject.toml b/pyproject.toml index 40c0e7e..6daf340 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "remoteterm-meshcore" -version = "3.6.6" +version = "3.6.7" description = "RemoteTerm - Web interface for MeshCore radio mesh networks" readme = "README.md" requires-python = ">=3.10" diff --git a/scripts/build/publish.sh b/scripts/build/publish.sh index 8e0af7e..24417c7 100755 --- a/scripts/build/publish.sh +++ b/scripts/build/publish.sh @@ -15,7 +15,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" source "$SCRIPT_DIR/release_common.sh" DOCKER_IMAGE="docker.io/jkingsman/remoteterm-meshcore" -DOCKER_PLATFORMS="linux/amd64,linux/arm64,linux/arm/v7" +DOCKER_PLATFORMS="linux/amd64,linux/arm64" VERSION="" NOTES_FILE="" SKIP_QUALITY=0 @@ -223,7 +223,6 @@ echo -e " - $DOCKER_IMAGE:$GIT_HASH" echo -e "Platforms:" echo -e " - linux/amd64" echo -e " - linux/arm64" -echo -e " - linux/arm/v7" echo -e "GitHub release:" echo -e " - $VERSION" echo -e "Release artifact:" diff --git a/scripts/build/push_docker_multiarch.sh b/scripts/build/push_docker_multiarch.sh index 2c7c4ca..bcd48e3 100644 --- a/scripts/build/push_docker_multiarch.sh +++ b/scripts/build/push_docker_multiarch.sh @@ -13,7 +13,7 @@ Options: --version VERSION Release version (required) --git-hash HASH Short git hash to tag alongside the version --image IMAGE Docker image name (default: docker.io/jkingsman/remoteterm-meshcore) - --platforms CSV Buildx platforms CSV (default: linux/amd64,linux/arm64,linux/arm/v7) + --platforms CSV Buildx platforms CSV (default: linux/amd64,linux/arm64) --help Show this message EOF } @@ -21,7 +21,7 @@ EOF VERSION="" GIT_HASH="" IMAGE="docker.io/jkingsman/remoteterm-meshcore" -PLATFORMS="linux/amd64,linux/arm64,linux/arm/v7" +PLATFORMS="linux/amd64,linux/arm64" while [ $# -gt 0 ]; do case "$1" in diff --git a/uv.lock b/uv.lock index 317991f..58868c0 100644 --- a/uv.lock +++ b/uv.lock @@ -1098,7 +1098,7 @@ wheels = [ [[package]] name = "remoteterm-meshcore" -version = "3.6.6" +version = "3.6.7" source = { virtual = "." } dependencies = [ { name = "aiomqtt" }, From 416166b07cc8e6787f42d68e0e496011e272a98a Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Tue, 31 Mar 2026 23:09:12 -0700 Subject: [PATCH 16/17] Add system arch data to debug output --- app/routers/debug.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/app/routers/debug.py b/app/routers/debug.py index f7573e9..53ae228 100644 --- a/app/routers/debug.py +++ b/app/routers/debug.py @@ -1,5 +1,8 @@ import hashlib import logging +import os +import platform +import struct import sys from datetime import datetime, timezone from typing import Any @@ -34,6 +37,13 @@ LOG_COPY_BOUNDARY_PREFIX = [ ] +class DebugSystemInfo(BaseModel): + os: str + arch: str + arch_bits: int + total_ram_mb: int + + class DebugApplicationInfo(BaseModel): version: str version_source: str @@ -95,6 +105,7 @@ class DebugDatabaseInfo(BaseModel): class DebugSnapshotResponse(BaseModel): captured_at: str + system: DebugSystemInfo application: DebugApplicationInfo health: HealthResponse runtime: DebugRuntimeInfo @@ -103,6 +114,23 @@ class DebugSnapshotResponse(BaseModel): logs: list[str] +def _build_system_info() -> DebugSystemInfo: + try: + # os.sysconf is available on Linux/macOS + page_size = os.sysconf("SC_PAGE_SIZE") + page_count = os.sysconf("SC_PHYS_PAGES") + total_ram_mb = (page_size * page_count) // (1024 * 1024) + except (AttributeError, ValueError, OSError): + total_ram_mb = 0 + + return DebugSystemInfo( + os=f"{platform.system()} {platform.release()}", + arch=platform.machine(), + arch_bits=struct.calcsize("P") * 8, + total_ram_mb=total_ram_mb, + ) + + def _build_application_info() -> DebugApplicationInfo: build_info = get_app_build_info() dirty_output = git_output("status", "--porcelain") @@ -272,6 +300,7 @@ async def debug_support_snapshot() -> DebugSnapshotResponse: ) return DebugSnapshotResponse( captured_at=datetime.now(timezone.utc).isoformat(), + system=_build_system_info(), application=_build_application_info(), health=HealthResponse(**health_data), runtime=DebugRuntimeInfo( From b4f3d1f14cc63c222ad4987606f4805938e89d3f Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Wed, 1 Apr 2026 11:31:20 -0700 Subject: [PATCH 17/17] Add additional info to debug endpoint. Closes #142. --- app/routers/debug.py | 26 +++++++++++++++++++++++++- tests/test_api.py | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/app/routers/debug.py b/app/routers/debug.py index 53ae228..5771531 100644 --- a/app/routers/debug.py +++ b/app/routers/debug.py @@ -12,8 +12,9 @@ from meshcore import EventType from pydantic import BaseModel, Field from app.config import get_recent_log_lines, settings +from app.models import AppSettings from app.radio_sync import get_contacts_selected_for_radio_sync, get_radio_channel_limit -from app.repository import MessageRepository, StatisticsRepository +from app.repository import AppSettingsRepository, MessageRepository, StatisticsRepository from app.routers.health import HealthResponse, build_health_data from app.services.radio_runtime import radio_runtime from app.version_info import get_app_build_info, git_output @@ -103,11 +104,21 @@ class DebugDatabaseInfo(BaseModel): total_outgoing: int +class DebugAppSettings(BaseModel): + max_radio_contacts: int + auto_decrypt_dm_on_advert: bool + advert_interval: int + flood_scope: str + blocked_keys_count: int + blocked_names_count: int + + class DebugSnapshotResponse(BaseModel): captured_at: str system: DebugSystemInfo application: DebugApplicationInfo health: HealthResponse + settings: DebugAppSettings runtime: DebugRuntimeInfo database: DebugDatabaseInfo radio_probe: DebugRadioProbe @@ -186,6 +197,17 @@ def _coerce_live_max_channels(device_info: dict[str, Any] | None) -> int | None: return None +def _build_debug_app_settings(app_settings: AppSettings) -> DebugAppSettings: + return DebugAppSettings( + max_radio_contacts=app_settings.max_radio_contacts, + auto_decrypt_dm_on_advert=app_settings.auto_decrypt_dm_on_advert, + advert_interval=app_settings.advert_interval, + flood_scope=app_settings.flood_scope, + blocked_keys_count=len(app_settings.blocked_keys), + blocked_names_count=len(app_settings.blocked_names), + ) + + async def _build_contact_audit( observed_contacts_payload: dict[str, dict[str, Any]], ) -> DebugContactAudit: @@ -293,6 +315,7 @@ async def _probe_radio() -> DebugRadioProbe: async def debug_support_snapshot() -> DebugSnapshotResponse: """Return a support/debug snapshot with recent logs and live radio state.""" health_data = await build_health_data(radio_runtime.is_connected, radio_runtime.connection_info) + app_settings = await AppSettingsRepository.get() message_totals = await StatisticsRepository.get_database_message_totals() radio_probe = await _probe_radio() channels_with_incoming_messages = ( @@ -303,6 +326,7 @@ async def debug_support_snapshot() -> DebugSnapshotResponse: system=_build_system_info(), application=_build_application_info(), health=HealthResponse(**health_data), + settings=_build_debug_app_settings(app_settings), runtime=DebugRuntimeInfo( connection_info=radio_runtime.connection_info, connection_desired=radio_runtime.connection_desired, diff --git a/tests/test_api.py b/tests/test_api.py index 4cf90f8..b5f3c3a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -213,6 +213,47 @@ class TestDebugEndpoint: assert response.status_code == 200 + @pytest.mark.asyncio + async def test_support_snapshot_includes_persisted_app_settings(self, test_db, client): + """Debug snapshot should expose the stored app settings row.""" + pub_key = "ab" * 32 + await _insert_contact(pub_key, "Alice") + + response = await client.patch( + "/api/settings", + json={ + "max_radio_contacts": 321, + "auto_decrypt_dm_on_advert": True, + "sidebar_sort_order": "alpha", + "advert_interval": 7200, + "flood_scope": "US-CA", + "blocked_keys": [pub_key], + "blocked_names": ["Mallory"], + }, + ) + assert response.status_code == 200 + + response = await client.post( + "/api/settings/favorites/toggle", + json={"type": "contact", "id": pub_key}, + ) + assert response.status_code == 200 + + response = await client.get("/api/debug") + + assert response.status_code == 200 + payload = response.json() + assert payload["settings"]["max_radio_contacts"] == 321 + assert payload["settings"]["auto_decrypt_dm_on_advert"] is True + assert payload["settings"]["advert_interval"] == 7200 + assert payload["settings"]["flood_scope"] == "#US-CA" + assert payload["settings"]["blocked_keys_count"] == 1 + assert payload["settings"]["blocked_names_count"] == 1 + assert "favorites" not in payload["settings"] + assert "blocked_keys" not in payload["settings"] + assert "blocked_names" not in payload["settings"] + assert "sidebar_sort_order" not in payload["settings"] + class TestRadioDisconnectedHandler: """Test that RadioDisconnectedError maps to 503."""