From 469b0203b74f5d64768ef871f6cab609599545fd Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 1 Jul 2026 16:14:55 -0400 Subject: [PATCH 1/2] fix(reticulum-daemon): die with parent to stop RNode-jamming pile-ups The daemon ships as a PyInstaller one-file binary; its direct parent is the bootloader, which the Rust supervisor (mesh/reticulum.rs Drop) stops via start_kill() == SIGKILL. SIGKILL can't be forwarded, so the Python child was orphaned on every link recreation and kept holding the RNode serial port. These stale daemons piled up (9 seen on one node), all clutching /dev/ttyUSB0 and garbling the RNode so it silently stopped transmitting (txb frozen, interface status False). Set PR_SET_PDEATHSIG(SIGTERM) at daemon startup so the kernel signals us when the parent exits; our existing SIGTERM handler then shuts down cleanly and frees the port. Linux-only, best-effort, no-op elsewhere. Co-Authored-By: Claude Opus 4.8 (1M context) --- reticulum-daemon/reticulum_daemon.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/reticulum-daemon/reticulum_daemon.py b/reticulum-daemon/reticulum_daemon.py index 2fd17985..3fb15eef 100644 --- a/reticulum-daemon/reticulum_daemon.py +++ b/reticulum-daemon/reticulum_daemon.py @@ -502,7 +502,32 @@ def _parse_args(argv): return p.parse_args(argv) +def _install_parent_death_signal() -> None: + """Die when our parent process does. + + The daemon ships as a PyInstaller one-file binary: our direct parent is the + bootloader, and the Rust supervisor (mesh/reticulum.rs) stops us by SIGKILL- + ing that bootloader. SIGKILL can't be forwarded, so without this the Python + child is orphaned and keeps holding the RNode serial port — which piles up + stale daemons that jam the radio (observed: 9 instances on one node). Asking + the kernel to send us SIGTERM on parent death lets our existing SIGTERM + handler shut down cleanly and free the port. Linux-only; no-op elsewhere. + """ + if sys.platform != "linux": + return + try: + import ctypes + + PR_SET_PDEATHSIG = 1 + ctypes.CDLL("libc.so.6", use_errno=True).prctl( + PR_SET_PDEATHSIG, signal.SIGTERM + ) + except Exception: + pass # best-effort; never block startup on this + + def main(argv=None) -> int: + _install_parent_death_signal() args = _parse_args(argv if argv is not None else sys.argv[1:]) if args.check: From 8256fde1a6110076b2f0f6b077d9c62c65f3382a Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 1 Jul 2026 18:04:31 -0400 Subject: [PATCH 2/2] fix(ui): mesh/web5/apps layout, modal, and search UX fixes - Mesh: fix 920-1280px bottom margin (phantom mobile-nav reservation leaking into the desktop-sidebar range), let the mesh view scale to full width on wide screens instead of capping at 1600px, and make the Device panel collapsible on desktop (previously mobile-only) - Search/controller-nav: a global gamepad/keyboard-nav feature was auto-clicking "the next button in the DOM" on Enter in any text input, which cleared the mesh peer search and popped the sideload modal from the App Store/My Apps search boxes. Opt out via data-controller-no-submit on all filter inputs; bump the mesh clear button's touch target - Modals: several (sideload, credential, Lightning channel open, identity create) used ad-hoc blue buttons and non-fullscreen backdrops that only covered the main content area, not the sidebar. Teleport them to body, unify backdrop/button theming to the dark+orange convention, fix the sideload modal's square bottom corners on desktop, and standardize close buttons to the ghost-icon style - Web5: remove the redundant/dead "Messages" tab from Connected Nodes (its deep-link was unreachable dead code); fix the "view message" toast to actually open the Archipelago channel instead of silently failing to match a LoRa peer; make identity rows responsive via a container query (viewport-based breakpoints don't work in the page's 2-column grid) and right-justify their action icons; collapse DID/DHT/Wallet/Nostr/Connected Nodes by default on mobile - Apps/App Store: match the search bar and sideload button's height, padding, and background to the mode-switcher tabs beside them - Mesh chat: keep the compose input focused after sending Co-Authored-By: Claude Sonnet 5 --- .../src/components/InstallVersionModal.vue | 2 +- neode-ui/src/composables/useControllerNav.ts | 13 +++- neode-ui/src/locales/en.json | 3 - neode-ui/src/locales/es.json | 3 - neode-ui/src/style.css | 40 ++++++++++++ neode-ui/src/views/Apps.vue | 59 ++++++++++++++---- neode-ui/src/views/Discover.vue | 6 +- neode-ui/src/views/Mesh.vue | 34 +++++++--- neode-ui/src/views/apps/AppIconGrid.vue | 35 ++++++----- neode-ui/src/views/apps/LightningChannels.vue | 6 +- .../views/apps/__tests__/AppIconGrid.test.ts | 4 ++ .../src/views/federation/NodeDetailModal.vue | 2 + neode-ui/src/views/mesh/mesh-styles.css | 47 +++++++++----- neode-ui/src/views/web5/Web5.vue | 15 +---- .../src/views/web5/Web5ConnectedNodes.vue | 62 ++----------------- neode-ui/src/views/web5/Web5Identities.vue | 33 ++++++++-- neode-ui/src/views/web5/Web5QuickActions.vue | 20 ++++++ .../web5/__tests__/Web5ConnectedNodes.test.ts | 27 -------- 18 files changed, 242 insertions(+), 169 deletions(-) diff --git a/neode-ui/src/components/InstallVersionModal.vue b/neode-ui/src/components/InstallVersionModal.vue index 47edbc73..36977f87 100644 --- a/neode-ui/src/components/InstallVersionModal.vue +++ b/neode-ui/src/components/InstallVersionModal.vue @@ -35,7 +35,7 @@
+
@@ -290,7 +297,9 @@
+ +
Sideload app

Install a trusted Docker image with a simple web UI.

- +
@@ -347,6 +360,7 @@
+
@@ -772,11 +786,11 @@ async function submitSideload() { align-items: center; justify-content: center; flex-shrink: 0; - width: 42px; - height: 42px; - border-radius: 0.75rem; - border: 1px solid rgba(255, 255, 255, 0.2); - background: rgba(255, 255, 255, 0.1); + width: 40px; + height: 40px; + border-radius: 0.5rem; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.78); transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease; } @@ -787,8 +801,8 @@ async function submitSideload() { color: white; } .sideload-icon-btn-mobile { - width: 48px; - height: 48px; + width: 52px; + height: 52px; } .sideload-modal { width: 100%; @@ -796,18 +810,37 @@ async function submitSideload() { max-height: calc(100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) - 12px); overflow-y: auto; border: 1px solid rgba(255, 255, 255, 0.14); + /* Bottom sheet on mobile (flush with the screen edge, so only the top + corners round); the md: breakpoint below switches to a centered + floating card, matching the wrapper's own items-end -> md:items-center + layout switch, so it gets fully rounded corners like every other modal. */ border-radius: 1.5rem 1.5rem 0 0; background: rgba(8, 10, 18, 0.94); padding: 1.25rem; padding-bottom: calc(1.25rem + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px))); box-shadow: 0 -24px 70px rgba(0, 0, 0, 0.55); } +@media (min-width: 768px) { + .sideload-modal { + border-radius: 1.5rem; + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55); + } +} .sideload-close-btn { + display: inline-flex; + align-items: center; + justify-content: center; width: 2.25rem; height: 2.25rem; border-radius: 0.75rem; - color: rgba(255, 255, 255, 0.55); - background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.7); + background: transparent; + transition: background-color 0.15s ease, color 0.15s ease; +} +.sideload-close-btn:hover, +.sideload-close-btn:focus-visible { + background: rgba(255, 255, 255, 0.1); + color: white; } .sideload-label { display: block; diff --git a/neode-ui/src/views/Discover.vue b/neode-ui/src/views/Discover.vue index 7361d00c..7e5cc663 100644 --- a/neode-ui/src/views/Discover.vue +++ b/neode-ui/src/views/Discover.vue @@ -54,7 +54,8 @@ type="text" placeholder="Search apps..." aria-label="Search apps" - class="app-header-search px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors" + data-controller-no-submit + class="app-header-search text-white placeholder-white/50 focus:outline-none transition-colors" /> @@ -84,7 +85,8 @@ type="text" placeholder="Search apps..." aria-label="Search apps" - class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors" + data-controller-no-submit + class="app-header-search w-full text-white placeholder-white/50 focus:outline-none transition-colors" /> diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue index 14b3b07a..64d910e1 100644 --- a/neode-ui/src/views/Mesh.vue +++ b/neode-ui/src/views/Mesh.vue @@ -48,6 +48,7 @@ const connectingDevice = ref(null) const showOnboardingModal = ref(false) const onboardingDismissed = ref>(new Set()) const chatScrollEl = ref(null) +const messageInputRef = ref(null) const mobileShowChat = ref(false) // Device status panel starts collapsed on mobile (expandable via its header). const deviceExpanded = ref(false) @@ -366,13 +367,21 @@ onMounted(async () => { await Promise.all([mesh.refreshAll(), transport.fetchStatus(), refreshFederationNodes(), refreshSelfOnion(), refreshSelfDid(), refreshContacts()]) refreshOutboxCount() // Deep-link from a message toast: open the sender's conversation if we can - // match it; otherwise just land on the mesh page (graceful fallback). + // match a LoRa mesh peer; otherwise open the Archipelago channel, since + // getReceivedMessages() (the toast's only message source) is exclusively + // Archipelago (Tor federation) traffic — a federation pubkey will never + // match an entry in mesh.peers, so without this fallback the deep-link + // silently failed and just landed on the bare mesh page every time. const targetPeer = typeof route.query.peer === 'string' ? route.query.peer : '' if (targetPeer) { const match = mesh.peers.find( (p) => p.pubkey_hex === targetPeer || p.did === targetPeer ) - if (match) openChat(match) + if (match) { + openChat(match) + } else { + openArchChannel() + } } // Start background polling for Archipelago (Tor) messages so unread count works loadArchMessages() @@ -913,7 +922,7 @@ async function handleSendMessage() { if (mesh.sending || sendingArch.value) return if (archChannelActive.value) { await sendArchMessage() - nextTick(() => scrollChatToBottom()) + nextTick(() => { scrollChatToBottom(); refocusMessageInput() }) return } // Pending reply: Send flushes as mesh.send-reply targeting the stashed @@ -931,7 +940,7 @@ async function handleSendMessage() { ) messageText.value = '' pendingEdit.value = null - nextTick(() => scrollChatToBottom()) + nextTick(() => { scrollChatToBottom(); refocusMessageInput() }) } catch (err: unknown) { sendError.value = err instanceof Error ? err.message : 'Edit failed' } @@ -949,7 +958,7 @@ async function handleSendMessage() { ) messageText.value = '' pendingReply.value = null - nextTick(() => scrollChatToBottom()) + nextTick(() => { scrollChatToBottom(); refocusMessageInput() }) } catch (err: unknown) { sendError.value = err instanceof Error ? err.message : 'Reply failed' } @@ -966,7 +975,7 @@ async function handleSendMessage() { await mesh.sendContent(activeChatPeer.value.contact_id, pendingAttachment.value.cid, caption) messageText.value = '' pendingAttachment.value = null - nextTick(() => scrollChatToBottom()) + nextTick(() => { scrollChatToBottom(); refocusMessageInput() }) } catch (err: unknown) { sendError.value = err instanceof Error ? err.message : 'Share failed' } @@ -978,11 +987,11 @@ async function handleSendMessage() { if (activeChatChannel.value) { await mesh.sendChannelMessage(activeChatChannel.value.index, messageText.value) messageText.value = '' - nextTick(() => scrollChatToBottom()) + nextTick(() => { scrollChatToBottom(); refocusMessageInput() }) } else if (activeChatPeer.value) { await mesh.sendMessage(activeChatPeer.value.contact_id, messageText.value) messageText.value = '' - nextTick(() => scrollChatToBottom()) + nextTick(() => { scrollChatToBottom(); refocusMessageInput() }) } } catch (err: unknown) { sendError.value = err instanceof Error ? err.message : 'Send failed' @@ -995,6 +1004,13 @@ function scrollChatToBottom() { } } +// Keep the compose field focused after a send so the user can keep typing +// without re-clicking it (Enter re-focuses natively, but the Send button +// click otherwise leaves focus on the button). +function refocusMessageInput() { + messageInputRef.value?.focus() +} + // Wheel over the chat must scroll ONLY the chat — never leak to the contacts // list or the page. Bound with `@wheel.stop.prevent`: `.stop` keeps the event // from reaching the global controller-nav wheel handler (which would otherwise @@ -1869,6 +1885,7 @@ async function downloadAttachment(payload: MeshAttachmentPayload) { class="mesh-peer-search" placeholder="Search contacts…" aria-label="Search contacts" + data-controller-no-submit /> +
@@ -99,7 +100,11 @@

{{ credentialModal.title }}

{{ credentialModal.description }}

- +
@@ -117,6 +122,7 @@
+
@@ -344,27 +350,22 @@ function scrollToPage(index: number) { transform: scale(0.88); } -.sideload-modal { - display: flex; - flex-direction: column; - width: 100%; - max-width: 34rem; - max-height: calc(100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) - 12px); - overflow: hidden; - border: 1px solid rgba(255, 255, 255, 0.14); - border-radius: 1.5rem 1.5rem 0 0; - background: rgba(8, 10, 18, 0.94); - padding: 1.25rem; - padding-bottom: calc(1.25rem + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px))); - box-shadow: 0 -24px 70px rgba(0, 0, 0, 0.55); -} .sideload-close-btn { + display: inline-flex; + align-items: center; + justify-content: center; width: 2.25rem; height: 2.25rem; flex-shrink: 0; border-radius: 0.75rem; - color: rgba(255, 255, 255, 0.55); - background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.7); + background: transparent; + transition: background-color 0.15s ease, color 0.15s ease; +} +.sideload-close-btn:hover, +.sideload-close-btn:focus-visible { + background: rgba(255, 255, 255, 0.1); + color: white; } .credential-modal-body { flex: 1 1 auto; diff --git a/neode-ui/src/views/apps/LightningChannels.vue b/neode-ui/src/views/apps/LightningChannels.vue index 6129a276..2a55d735 100644 --- a/neode-ui/src/views/apps/LightningChannels.vue +++ b/neode-ui/src/views/apps/LightningChannels.vue @@ -129,6 +129,7 @@
+

Open Channel

@@ -166,15 +167,17 @@
+
+

Close Channel?

@@ -194,6 +197,7 @@
+
diff --git a/neode-ui/src/views/apps/__tests__/AppIconGrid.test.ts b/neode-ui/src/views/apps/__tests__/AppIconGrid.test.ts index abd9d664..5322944b 100644 --- a/neode-ui/src/views/apps/__tests__/AppIconGrid.test.ts +++ b/neode-ui/src/views/apps/__tests__/AppIconGrid.test.ts @@ -78,6 +78,10 @@ describe('AppIconGrid', () => { props: { apps: [['filebrowser', makePkg('filebrowser')]] }, global: { plugins: [pinia], + // The credential modal is 'd (so its full-screen + // backdrop isn't clipped by the dashboard's transformed layout) — + // stub it to render inline so wrapper.text() still sees it. + stubs: { teleport: true }, }, }) diff --git a/neode-ui/src/views/federation/NodeDetailModal.vue b/neode-ui/src/views/federation/NodeDetailModal.vue index 843446ee..9dc07f81 100644 --- a/neode-ui/src/views/federation/NodeDetailModal.vue +++ b/neode-ui/src/views/federation/NodeDetailModal.vue @@ -1,4 +1,5 @@ diff --git a/neode-ui/src/views/web5/Web5ConnectedNodes.vue b/neode-ui/src/views/web5/Web5ConnectedNodes.vue index 834280c2..ac84816c 100644 --- a/neode-ui/src/views/web5/Web5ConnectedNodes.vue +++ b/neode-ui/src/views/web5/Web5ConnectedNodes.vue @@ -57,15 +57,6 @@ {{ t('web5.observers') }} ({{ observers.length }}) - -