From 4c3a2e6f775488568ae136642895cdd394571a32 Mon Sep 17 00:00:00 2001 From: Ivan Date: Mon, 21 Sep 2026 04:44:12 -0500 Subject: [PATCH 01/13] fix(nomadnet): restore exclusive announces list branches The load-more spinner's standalone v-if sat between the virtualized list and the plain-list v-else-if, severing the conditional chain. With 32 or more announces both lists mounted at once: the plain list rendered every row below the fold, doubled scroll handlers, and produced a second scroll container that chained wheel events to the page. Moving the spinner after the else chain reattaches the branches so exactly one list renders. --- .../nomadnetwork/NomadNetworkSidebar.vue | 12 ++--- tests/frontend/NomadNetworkSidebar.test.js | 54 +++++++++++++++++++ 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue index aab7d781..6e3c63e2 100644 --- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue +++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue @@ -674,12 +674,6 @@ -
- -
+
+ +
diff --git a/tests/frontend/NomadNetworkSidebar.test.js b/tests/frontend/NomadNetworkSidebar.test.js index ae8d0074..4b3af109 100644 --- a/tests/frontend/NomadNetworkSidebar.test.js +++ b/tests/frontend/NomadNetworkSidebar.test.js @@ -327,6 +327,60 @@ describe("NomadNetworkSidebar.vue", () => { expect(wrapper.text()).not.toContain("nomadnet.no_search_results_peers"); }); + describe("announces list virtualization branches", () => { + const makeNodes = (count) => { + const nodes = {}; + for (let i = 0; i < count; i++) { + const hash = String(i).padStart(32, "0"); + nodes[hash] = { + destination_hash: hash, + identity_hash: String(i + 1).padStart(32, "f"), + display_name: `Node ${i}`, + updated_at: new Date().toISOString(), + }; + } + return nodes; + }; + + const openAnnouncesTab = async (wrapper) => { + const announceTab = wrapper.findAll("button").find((b) => b.text().includes("nomadnet.announces")); + await announceTab.trigger("click"); + await wrapper.vm.$nextTick(); + }; + + it("mounts only the virtualized list at or above the virtualization threshold", async () => { + const wrapper = mountSidebar({ nodes: makeNodes(40), totalNodesCount: 40 }); + await openAnnouncesTab(wrapper); + + // regression: the plain list must not mount alongside the virtual list + expect(wrapper.find("div.h-full.overflow-y-auto.space-y-2").exists()).toBe(false); + expect(wrapper.find("div.h-full.overflow-y-auto.overflow-x-hidden").exists()).toBe(true); + expect(wrapper.text()).not.toContain("nomadnet.no_announces_yet"); + }); + + it("keeps the plain list unmounted while loading more on the virtualized list", async () => { + const wrapper = mountSidebar({ + nodes: makeNodes(40), + totalNodesCount: 40, + isLoadingMoreNodes: true, + hasMoreNodes: true, + }); + await openAnnouncesTab(wrapper); + + expect(wrapper.find("div.h-full.overflow-y-auto.space-y-2").exists()).toBe(false); + expect(wrapper.find('[data-icon-name="loading"]').exists()).toBe(true); + }); + + it("renders every announce card in the plain list below the virtualization threshold", async () => { + const wrapper = mountSidebar({ nodes: makeNodes(5), totalNodesCount: 5 }); + await openAnnouncesTab(wrapper); + + expect(wrapper.find("div.h-full.overflow-y-auto.space-y-2").exists()).toBe(true); + expect(wrapper.findAll(".announce-card")).toHaveLength(5); + expect(wrapper.text()).toContain("Node 4"); + }); + }); + it("favouriteDisplayName prefers announce cache over unknown favourite label", async () => { const favHash = defaultFavourite.destination_hash; const wrapper = mountSidebar({ From 23dc68cdb0cd03f3bdc37c1f07901a632b957c21 Mon Sep 17 00:00:00 2001 From: Ivan Date: Mon, 21 Sep 2026 05:01:19 -0500 Subject: [PATCH 02/13] fix(nomadnet): cancel armed touch gestures on reversal or interruption A swipe-back or pull-to-refresh that armed during touchmove kept its distance when the finger reversed direction, so touchend still fired the action on a stale value. touchcancel also shared the touchend handler, letting interrupted gestures trigger navigation or reload. Clear navSwipeBackDistance before evaluating pull intent in touchmove, evaluate pull state unconditionally so reversals zero it, and route touchcancel to a reset-only handler. --- .../nomadnetwork/NomadNetworkPage.vue | 18 +++- tests/frontend/NomadNetworkPage.test.js | 102 ++++++++++++++++++ 2 files changed, 115 insertions(+), 5 deletions(-) diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue index 9e39b906..cbbcaae6 100644 --- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue +++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue @@ -563,7 +563,7 @@ @touchstart.passive="onNodeContainerTouchStart" @touchmove.passive="onNodeContainerTouchMove" @touchend="onNodeContainerTouchEnd" - @touchcancel="onNodeContainerTouchEnd" + @touchcancel="onNodeContainerTouchCancel" >
0 && dy > Math.abs(dx)) { - this.navPullDistance = Math.min(dy, 160); - this.navSwipeBackDistance = 0; - } + const pulling = this.navTouchStartScrollTop <= 0 && el.scrollTop <= 0 && dy > 0 && dy > Math.abs(dx); + this.navPullDistance = pulling ? Math.min(dy, 160) : 0; }, onNodeContainerTouchEnd() { if (this.navSwipeBackDistance >= NAV_SWIPE_BACK_TRIGGER_PX) { @@ -4015,6 +4016,13 @@ export default { this.navSwipeBackDistance = 0; this.navPullDistance = 0; }, + onNodeContainerTouchCancel() { + // An interrupted gesture (alert, gesture conflict) is not a + // completed swipe or pull, so it must not trigger either action. + this.navSwipeEdgeActive = false; + this.navSwipeBackDistance = 0; + this.navPullDistance = 0; + }, fetchArchives() { if (this.isPrivate) { this.pageArchives = []; diff --git a/tests/frontend/NomadNetworkPage.test.js b/tests/frontend/NomadNetworkPage.test.js index bff07f3f..75bd63bf 100644 --- a/tests/frontend/NomadNetworkPage.test.js +++ b/tests/frontend/NomadNetworkPage.test.js @@ -1842,4 +1842,106 @@ describe("NomadNetworkPage.vue", () => { wrapper.unmount(); }); }); + + describe("touch gestures", () => { + const makeContainer = () => ({ + scrollTop: 0, + getBoundingClientRect: () => ({ left: 0, top: 0, right: 800, bottom: 600 }), + }); + const touchEvent = (x, y, el) => ({ + touches: [{ clientX: x, clientY: y }], + currentTarget: el, + }); + + it("completed edge swipe still navigates back", () => { + const wrapper = mountNomadNetworkPage(); + wrapper.vm.nodePagePathHistory = ["prev"]; + const navSpy = vi.spyOn(wrapper.vm, "loadPreviousNodePage").mockImplementation(() => {}); + const el = makeContainer(); + + wrapper.vm.onNodeContainerTouchStart(touchEvent(10, 100, el)); + wrapper.vm.onNodeContainerTouchMove(touchEvent(110, 100, el)); + expect(wrapper.vm.navSwipeBackDistance).toBe(100); + + wrapper.vm.onNodeContainerTouchEnd(); + expect(navSpy).toHaveBeenCalledTimes(1); + expect(wrapper.vm.navSwipeBackDistance).toBe(0); + wrapper.unmount(); + }); + + it("reversing direction mid-gesture cancels an armed swipe", () => { + const wrapper = mountNomadNetworkPage(); + wrapper.vm.nodePagePathHistory = ["prev"]; + const navSpy = vi.spyOn(wrapper.vm, "loadPreviousNodePage").mockImplementation(() => {}); + const reloadSpy = vi.spyOn(wrapper.vm, "reloadNodePage").mockImplementation(() => {}); + const el = makeContainer(); + + wrapper.vm.onNodeContainerTouchStart(touchEvent(10, 100, el)); + wrapper.vm.onNodeContainerTouchMove(touchEvent(110, 100, el)); + expect(wrapper.vm.navSwipeBackDistance).toBe(100); + + // finger drifts back and up before release: the armed distance must not survive + wrapper.vm.onNodeContainerTouchMove(touchEvent(30, 60, el)); + expect(wrapper.vm.navSwipeBackDistance).toBe(0); + + wrapper.vm.onNodeContainerTouchEnd(); + expect(navSpy).not.toHaveBeenCalled(); + expect(reloadSpy).not.toHaveBeenCalled(); + wrapper.unmount(); + }); + + it("reversing direction mid-gesture cancels an armed pull", () => { + const wrapper = mountNomadNetworkPage(); + const reloadSpy = vi.spyOn(wrapper.vm, "reloadNodePage").mockImplementation(() => {}); + const el = makeContainer(); + + // start away from the left edge so the pull branch is evaluated + wrapper.vm.onNodeContainerTouchStart(touchEvent(200, 100, el)); + wrapper.vm.onNodeContainerTouchMove(touchEvent(200, 220, el)); + expect(wrapper.vm.navPullDistance).toBe(120); + + // finger drags back above the start point before release + wrapper.vm.onNodeContainerTouchMove(touchEvent(200, 80, el)); + expect(wrapper.vm.navPullDistance).toBe(0); + + wrapper.vm.onNodeContainerTouchEnd(); + expect(reloadSpy).not.toHaveBeenCalled(); + wrapper.unmount(); + }); + + it("touchcancel does not fire an armed swipe-back", () => { + const wrapper = mountNomadNetworkPage(); + wrapper.vm.nodePagePathHistory = ["prev"]; + const navSpy = vi.spyOn(wrapper.vm, "loadPreviousNodePage").mockImplementation(() => {}); + const el = makeContainer(); + + wrapper.vm.onNodeContainerTouchStart(touchEvent(10, 100, el)); + wrapper.vm.onNodeContainerTouchMove(touchEvent(110, 100, el)); + expect(wrapper.vm.navSwipeBackDistance).toBe(100); + + wrapper.vm.onNodeContainerTouchCancel(); + expect(navSpy).not.toHaveBeenCalled(); + expect(wrapper.vm.navSwipeEdgeActive).toBe(false); + expect(wrapper.vm.navSwipeBackDistance).toBe(0); + expect(wrapper.vm.navPullDistance).toBe(0); + wrapper.unmount(); + }); + + it("touchcancel does not fire an armed pull-to-refresh", () => { + const wrapper = mountNomadNetworkPage(); + const reloadSpy = vi.spyOn(wrapper.vm, "reloadNodePage").mockImplementation(() => {}); + const el = makeContainer(); + + wrapper.vm.onNodeContainerTouchStart(touchEvent(200, 100, el)); + wrapper.vm.onNodeContainerTouchMove(touchEvent(200, 220, el)); + expect(wrapper.vm.navPullDistance).toBe(120); + + wrapper.vm.onNodeContainerTouchCancel(); + expect(reloadSpy).not.toHaveBeenCalled(); + expect(wrapper.vm.navSwipeEdgeActive).toBe(false); + expect(wrapper.vm.navSwipeBackDistance).toBe(0); + expect(wrapper.vm.navPullDistance).toBe(0); + wrapper.unmount(); + }); + }); }); From aa384c3f95209f7fa59d1d68bb04c8a782835adb Mon Sep 17 00:00:00 2001 From: Ivan Date: Mon, 21 Sep 2026 16:48:05 -0500 Subject: [PATCH 03/13] fix(electron): drop chromium gpu and logging flags from backend argv The GPU crash-storm fallback relaunches with --disable-gpu and friends in process.argv, which leaked through getUserProvidedArguments into the backend spawn and the shell path guard. Filter them alongside --no-sandbox so only real user flags reach the Python side. --- electron/mainHelpers.js | 11 ++++++++++- tests/electron/mainHelpers.test.js | 12 ++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/electron/mainHelpers.js b/electron/mainHelpers.js index 55db82f4..c95e7534 100644 --- a/electron/mainHelpers.js +++ b/electron/mainHelpers.js @@ -2,7 +2,16 @@ const path = require("node:path"); -const IGNORED_CLI_ARGUMENTS = new Set(["--no-sandbox", "--ozone-platform-hint=auto"]); +const IGNORED_CLI_ARGUMENTS = new Set([ + "--no-sandbox", + "--ozone-platform-hint=auto", + "--disable-gpu", + "--disable-gpu-sandbox", + "--disable-gpu-compositing", + "--disable-software-rasterizer", + "--enable-logging", + "--enable-logging=stderr", +]); /** * Arguments after argv[0], excluding known Chromium/Electron noise flags. diff --git a/tests/electron/mainHelpers.test.js b/tests/electron/mainHelpers.test.js index 8f129a64..280de01f 100644 --- a/tests/electron/mainHelpers.test.js +++ b/tests/electron/mainHelpers.test.js @@ -18,6 +18,18 @@ describe("electron/mainHelpers", () => { expect(getUserProvidedArguments(argv)).toEqual(["--no-https", "--port", "1"]); }); + it("getUserProvidedArguments drops chromium gpu flags so the backend never sees them", () => { + const argv = [ + "/app/electron", + "--disable-gpu", + "--disable-gpu-sandbox", + "--enable-logging", + "--storage-dir", + "D:/data", + ]; + expect(getUserProvidedArguments(argv)).toEqual(["--storage-dir", "D:/data"]); + }); + it("formatRenderProcessGoneDetails handles null/undefined", () => { expect(formatRenderProcessGoneDetails(null)).toBe("no details"); expect(formatRenderProcessGoneDetails(undefined)).toBe("no details"); From 4833943468a6225a4d09baa855c599f716083c24 Mon Sep 17 00:00:00 2001 From: Ivan Date: Mon, 21 Sep 2026 16:48:11 -0500 Subject: [PATCH 04/13] fix(nomadnet): bound link requests with a watchdog RNS only invokes failed_callback once a request receipt reaches DELIVERED, so a packet request stuck in SENT, an unanswered request, or a link torn down mid-request never timed out and the download spun forever. Add an async watchdog that fails the request when the link closes or the response window lapses, extending the deadline on progress. Also handle link.request raising or returning no receipt by evicting the cached link and reporting a clean failure. --- meshchatx/src/backend/nomadnet_downloader.py | 84 +++++++++++++++-- tests/backend/test_nomadnet_downloader.py | 95 ++++++++++++++++++++ 2 files changed, 171 insertions(+), 8 deletions(-) diff --git a/meshchatx/src/backend/nomadnet_downloader.py b/meshchatx/src/backend/nomadnet_downloader.py index ef08fcbe..b9ac68ad 100644 --- a/meshchatx/src/backend/nomadnet_downloader.py +++ b/meshchatx/src/backend/nomadnet_downloader.py @@ -10,6 +10,7 @@ import RNS from meshchatx.src.backend import reticulum_pathfinding +from meshchatx.src.backend.async_utils import AsyncUtils from meshchatx.src.backend.path_utils import ( link_establishment_window, path_response_window, @@ -31,6 +32,10 @@ # Wait granularity while polling for path / link (seconds). Smaller = faster reaction, slightly more wakeups. _POLL_INTERVAL_S = 0.02 +# Floor for the request watchdog. RNS scales the window by link RTT, but a +# zero-ish RTT on loopback/test links would otherwise cut off slow nodes. +_MIN_REQUEST_WATCHDOG_S = 30.0 + logger = logging.getLogger(__name__) @@ -465,14 +470,77 @@ def link_established(self, link): self._maybe_identify(link) - self.request_receipt = link.request( - self.path, - data=self.data, - response_callback=self.on_response, - failed_callback=self.on_failed, - progress_callback=self.on_progress, - timeout=self.timeout, - ) + try: + receipt = link.request( + self.path, + data=self.data, + response_callback=self.on_response, + failed_callback=self.on_failed, + progress_callback=self.on_progress, + timeout=self.timeout, + ) + except Exception as exc: + _uncache_link_if_matches(self.destination_hash, link) + self._deliver_failure(str(exc) or "Could not send request to node.") + self._maybe_teardown_abandoned_link() + return + if not receipt: + _uncache_link_if_matches(self.destination_hash, link) + self._deliver_failure("Could not send request to node.") + self._maybe_teardown_abandoned_link() + return + self.request_receipt = receipt + AsyncUtils.run_async(self._watch_request_receipt(link)) + + async def _watch_request_receipt(self, link) -> None: + """Bound the wait for a link request to conclude. + + RNS invokes failed_callback only once a receipt reaches DELIVERED. + A packet request stuck in SENT, an unanswered request, or a link + torn down mid-request, never times out upstream, so enforce the + window here. + """ + receipt = self.request_receipt + if receipt is None: + return + try: + if self.timeout is not None: + window = float(self.timeout) + else: + rtt = getattr(link, "rtt", None) or 0.0 + factor = getattr( + link, + "traffic_timeout_factor", + RNS.Link.TRAFFIC_TIMEOUT_FACTOR, + ) + window = rtt * factor + RNS.Resource.RESPONSE_MAX_GRACE_TIME * 1.125 + window = max(window, _MIN_REQUEST_WATCHDOG_S) + except Exception: + window = _MIN_REQUEST_WATCHDOG_S + + deadline = time.monotonic() + window + last_progress = getattr(receipt, "progress", None) or 0.0 + reason = None + while not self._outcome_delivered and not self.is_cancelled: + if link.status is RNS.Link.CLOSED: + reason = "Link to node closed before the request completed." + break + if time.monotonic() > deadline: + reason = "Request timed out. The node did not respond." + break + await asyncio.sleep(_POLL_INTERVAL_S) + progress = getattr(receipt, "progress", None) or 0.0 + if progress != last_progress: + last_progress = progress + deadline = time.monotonic() + window + + if reason is None or self.is_cancelled or self._outcome_delivered: + return + # A request that never concluded means the link is dead or + # half-open: evict it so the next attempt does not reuse it. + _uncache_link_if_matches(self.destination_hash, link) + self._deliver_failure(reason) + self._maybe_teardown_abandoned_link() def on_response(self, request_receipt: RNS.RequestReceipt): if self.is_cancelled or self._outcome_delivered: diff --git a/tests/backend/test_nomadnet_downloader.py b/tests/backend/test_nomadnet_downloader.py index 28f23afa..5eafba06 100644 --- a/tests/backend/test_nomadnet_downloader.py +++ b/tests/backend/test_nomadnet_downloader.py @@ -489,3 +489,98 @@ async def test_download_fails_cleanly_when_identity_missing(): assert failures assert "identity" in failures[0].lower() nudge.assert_called_once_with(dest) + + +@pytest.mark.asyncio +async def test_request_watchdog_times_out_unanswered_request(): + # RNS only fires failed_callback once a receipt reaches DELIVERED, so a + # packet request stuck in SENT must be bounded by the watchdog. + from meshchatx.src.backend import nomadnet_downloader as nd + + failures = [] + d = NomadnetDownloader( + b"ef" * 8, + "/p", + None, + MagicMock(), + lambda reason: failures.append(reason), + MagicMock(), + timeout=0.05, + ) + link = MagicMock() + link.status = RNS.Link.ACTIVE + receipt = MagicMock() + receipt.progress = 0.0 + d.request_receipt = receipt + d.link = link + + with patch.object(nd, "_MIN_REQUEST_WATCHDOG_S", 0.05): + await d._watch_request_receipt(link) + + assert failures == ["Request timed out. The node did not respond."] + link.teardown.assert_called_once() + + +@pytest.mark.asyncio +async def test_request_watchdog_fails_on_closed_link(): + failures = [] + d = NomadnetDownloader( + b"ef" * 8, + "/p", + None, + MagicMock(), + lambda reason: failures.append(reason), + MagicMock(), + ) + link = MagicMock() + link.status = RNS.Link.CLOSED + d.request_receipt = MagicMock() + d.link = link + + await d._watch_request_receipt(link) + + assert failures == ["Link to node closed before the request completed."] + + +@pytest.mark.asyncio +async def test_request_watchdog_ignores_concluded_request(): + failures = [] + d = NomadnetDownloader( + b"ef" * 8, + "/p", + None, + MagicMock(), + lambda reason: failures.append(reason), + MagicMock(), + timeout=0.05, + ) + link = MagicMock() + link.status = RNS.Link.ACTIVE + d.request_receipt = MagicMock() + d._outcome_delivered = True + d.link = link + + await d._watch_request_receipt(link) + + assert failures == [] + link.teardown.assert_not_called() + + +def test_link_established_fails_when_request_send_fails(): + failures = [] + d = NomadnetDownloader( + b"ef" * 8, + "/p", + None, + MagicMock(), + lambda reason: failures.append(reason), + MagicMock(), + ) + link = MagicMock() + link.status = RNS.Link.ACTIVE + link.request = MagicMock(return_value=False) + d.link = link + + d.link_established(link) + + assert failures == ["Could not send request to node."] From 34733f1bc24f05b875679ba6eee109d7b69d9f4d Mon Sep 17 00:00:00 2001 From: Ivan Date: Mon, 21 Sep 2026 16:48:19 -0500 Subject: [PATCH 05/13] fix(nomadnet): resend in-flight downloads after websocket reconnect Downloads issued on the previous socket report results to the dead client, so pages, files, and images spun forever after a reconnect. Keep the sent payload on each callback entry and, on reconnect, cancel the orphaned backend transfer by its old download id before re-issuing the request on the new socket. Ids are cleared first so the stale cancelled event cannot purge the resent entry or flash cancelled UI. --- .../nomadnetwork/NomadNetworkPage.vue | 71 ++++++++++++++++--- 1 file changed, 60 insertions(+), 11 deletions(-) diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue index cbbcaae6..45cbf3ce 100644 --- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue +++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue @@ -1672,6 +1672,46 @@ export default { onWebsocketReconnected() { this.getFavourites(); this.getNomadnetworkNodeAnnounces(); + this.resendInFlightNomadDownloads(); + }, + // Downloads sent on the previous socket report their results to the + // dead client and never arrive here, so the page/file/image would + // spin forever. Cancel the orphaned backend transfer and re-issue + // the request on the new socket. + resendInFlightNomadDownloads() { + for (const map of [this.nomadnetPageDownloadCallbacks, this.nomadnetFileDownloadCallbacks]) { + for (const key of Object.keys(map || {})) { + const entry = map[key]; + if (!entry || !entry.sent || typeof entry.payload !== "string") { + continue; + } + const oldDownloadId = entry.downloadId; + entry.downloadId = null; + if (oldDownloadId != null) { + // clearing ids first keeps the "cancelled" event for + // the old download from purging the resent entry or + // flashing the cancelled UI state + if (entry.primary && this.currentPageDownloadId === oldDownloadId) { + this.currentPageDownloadId = null; + } + if (this.currentFileDownloadId === oldDownloadId) { + this.currentFileDownloadId = null; + } + for (const imageContext of Object.values(this.nomadImageDownloadCallbacks || {})) { + if (imageContext && imageContext.downloadId === oldDownloadId) { + imageContext.downloadId = null; + } + } + WebSocketConnection.send( + JSON.stringify({ + type: "nomadnet.download.cancel", + download_id: oldDownloadId, + }) + ); + } + WebSocketConnection.send(entry.payload); + } + } }, startFavouritesPollInterval() { if (this.reloadInterval) { @@ -4189,13 +4229,14 @@ export default { ) { try { // set callbacks for nomadnet filePath download - this.nomadnetFileDownloadCallbacks[this.getNomadnetFileDownloadCallbackKey(destinationHash, filePath)] = - { - onSuccessCallback: onSuccessCallback, - onFailureCallback: onFailureCallback, - onProgressCallback: onProgressCallback, - requestId: data && data.request_id != null ? data.request_id : null, - }; + const callbackKey = this.getNomadnetFileDownloadCallbackKey(destinationHash, filePath); + const entry = { + onSuccessCallback: onSuccessCallback, + onFailureCallback: onFailureCallback, + onProgressCallback: onProgressCallback, + requestId: data && data.request_id != null ? data.request_id : null, + }; + this.nomadnetFileDownloadCallbacks[callbackKey] = entry; // ask reticulum to download file from nomadnet const payload = { @@ -4212,13 +4253,17 @@ export default { payload.request_id = data.request_id; } } - if (!WebSocketConnection.send(JSON.stringify(payload))) { - delete this.nomadnetFileDownloadCallbacks[ - this.getNomadnetFileDownloadCallbackKey(destinationHash, filePath) - ]; + const payloadStr = JSON.stringify(payload); + if (!WebSocketConnection.send(payloadStr)) { + delete this.nomadnetFileDownloadCallbacks[callbackKey]; if (onFailureCallback) { onFailureCallback(this.$t("nomadnet.websocket_not_connected")); } + } else { + // kept so resendInFlightNomadDownloads() can re-issue + // this request after a websocket reconnect + entry.payload = payloadStr; + entry.sent = true; } } catch (e) { console.error(e); @@ -4308,6 +4353,10 @@ export default { const trySend = () => { if (WebSocketConnection.send(payload)) { + // kept so resendInFlightNomadDownloads() can re-issue + // this request after a websocket reconnect + entry.payload = payload; + entry.sent = true; cancelPendingSend(); return true; } From b57778eb8f235519c3ea5c5006869400ebadecf2 Mon Sep 17 00:00:00 2001 From: Ivan Date: Mon, 21 Sep 2026 16:48:19 -0500 Subject: [PATCH 06/13] fix(tutorial): pin page footer and honor bootstrap-only default Move the page-mode navigation buttons out of the scroll area into a pinned footer so Back/Continue stay reachable on small screens. Stop forcing default_bootstrap_only on in the recommended and discovery modes; the backend default is off since it was decoupled from the Reticulum config, so the tutorial was silently overriding it. Update the English hint text to match. --- .../src/frontend/components/TutorialModal.vue | 98 ++++++++++--------- meshchatx/src/frontend/locales/en.json | 2 +- 2 files changed, 52 insertions(+), 48 deletions(-) diff --git a/meshchatx/src/frontend/components/TutorialModal.vue b/meshchatx/src/frontend/components/TutorialModal.vue index 2fe69cbd..fdbca7c9 100644 --- a/meshchatx/src/frontend/components/TutorialModal.vue +++ b/meshchatx/src/frontend/components/TutorialModal.vue @@ -1148,7 +1148,7 @@
-
+
+
+
+ + +
+
+ +
- -
+
-
-
- - - + - -
+
@@ -2352,7 +2356,7 @@ export default { discoveryInterval: null, markingSeen: false, windowWidth: typeof window !== "undefined" ? window.innerWidth : 1024, - defaultBootstrapOnly: true, + defaultBootstrapOnly: false, bootstrapListSearch: "", bootstrapDiscoveredSectionOpen: true, bootstrapCommunitySectionOpen: true, @@ -2693,10 +2697,10 @@ export default { const payload = { discover_interfaces: true, autoconnect_discovered_interfaces: 3, - default_bootstrap_only: true, + default_bootstrap_only: false, }; await window.api.patch(apiPath("/reticulum/discovery"), payload); - this.defaultBootstrapOnly = true; + this.defaultBootstrapOnly = false; ToastUtils.success(this.$t("tutorial.mode_recommended_added")); this.connectionMode = "recommended"; @@ -2730,10 +2734,10 @@ export default { const payload = { discover_interfaces: true, autoconnect_discovered_interfaces: 3, - default_bootstrap_only: true, + default_bootstrap_only: false, }; await window.api.patch(apiPath("/reticulum/discovery"), payload); - this.defaultBootstrapOnly = true; + this.defaultBootstrapOnly = false; ToastUtils.success(this.$t("tutorial.discovery_enabled")); this.connectionMode = "discovery"; this.currentStep = 4; @@ -3015,10 +3019,10 @@ export default { try { const response = await window.api.get(apiPath("/reticulum/discovery")); const d = response.data?.discovery ?? {}; - this.defaultBootstrapOnly = this.parseDiscoveryBool(d.default_bootstrap_only, true); + this.defaultBootstrapOnly = this.parseDiscoveryBool(d.default_bootstrap_only); } catch (e) { console.error(e); - this.defaultBootstrapOnly = true; + this.defaultBootstrapOnly = false; } }, async persistDefaultBootstrapOnly(value) { diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json index a7c03147..c4841d65 100644 --- a/meshchatx/src/frontend/locales/en.json +++ b/meshchatx/src/frontend/locales/en.json @@ -5222,7 +5222,7 @@ "bootstrap_search_no_match": "No nodes match your search", "bootstrap_search_clear": "Clear search", "bootstrap_only_label": "Bootstrap-only for added interfaces", - "bootstrap_only_hint": "On by default. Bootstrap interfaces are temporary starting points; Reticulum can drop them after it finds enough stable links. Leave this on to avoid holding many TCP connections open. Turn it off if you want a persistent link to a chosen node. The default is adjustable in Interfaces, Discovery settings.", + "bootstrap_only_hint": "Off by default. Bootstrap interfaces are temporary starting points; Reticulum can drop them after it finds enough stable links. Turn this on to avoid holding many TCP connections open. Leave it off if you want a persistent link to a chosen node. The default is adjustable in Interfaces, Discovery settings.", "bootstrap_desc_page": "Bootstrap nodes give Reticulum a starting point so it can discover the rest of the network. Select one or more from discovered or community nodes below.", "bootstrap_discovered": "Discovered Nodes", "bootstrap_community": "Community Nodes", From 71653e7f246635f64a6777bd701978533f48aca7 Mon Sep 17 00:00:00 2001 From: Ivan Date: Mon, 21 Sep 2026 16:48:26 -0500 Subject: [PATCH 07/13] fix(map): inline desktop search and re-anchor onboarding tooltip Move the location search bar into the header row on sm and up so it no longer overlays the map; the absolute overlay is now mobile-only behind the search toggle. Point the onboarding tooltip at the map tools button instead of the drawing toolbar export button, positioning it below the button with a straight arrow, and drop the xl top offset that fought the toolbar layout. --- .../src/frontend/components/map/MapPage.vue | 53 ++++++++++++------- .../map/internal/MapDrawingToolbar.vue | 2 +- 2 files changed, 36 insertions(+), 19 deletions(-) diff --git a/meshchatx/src/frontend/components/map/MapPage.vue b/meshchatx/src/frontend/components/map/MapPage.vue index 65f2876e..a0a5b572 100644 --- a/meshchatx/src/frontend/components/map/MapPage.vue +++ b/meshchatx/src/frontend/components/map/MapPage.vue @@ -67,6 +67,25 @@
+ + +